replace string settings with typed enums

- preserve local wall-time handling and import completion timestamps
- validate invalid properties without overwriting items
This commit is contained in:
Chili Palmer
2026-08-19 21:44:10 +02:00
parent 1f9f00ae7a
commit a6b38af396
7 changed files with 494 additions and 239 deletions

View File

@@ -15,7 +15,10 @@ use crate::{
MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name, MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name,
parse_key_binding, parse_key_binding,
}, },
model::{Category, DocumentSettings, Item, ItemChanges, MacroDef, ViewDef}, model::{
Category, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef,
TrashPolicy, ViewDef, ViewKind, WeekStart,
},
preferences::AppPreferences, preferences::AppPreferences,
}; };
@@ -397,7 +400,7 @@ impl App {
let today = Local::now().date_naive(); let today = Local::now().date_naive();
if today != self.current_day { if today != self.current_day {
self.current_day = today; self.current_day = today;
if self.document_settings.trash_policy == "end-of-day" { if self.document_settings.trash_policy == TrashPolicy::EndOfDay {
let count = self.db.empty_trash()?; let count = self.db.empty_trash()?;
if count > 0 { if count > 0 {
self.status = format!("Emptied {count} Trash item(s) at end of day"); self.status = format!("Emptied {count} Trash item(s) at end of day");
@@ -985,9 +988,9 @@ impl App {
if ids.is_empty() { if ids.is_empty() {
return Ok(()); return Ok(());
} }
let restore = self.current_view().kind == "trash"; let restore = self.current_view().kind == ViewKind::Trash;
if !restore if !restore
&& self.document_settings.trash_policy == "immediate" && self.document_settings.trash_policy == TrashPolicy::Immediate
&& self.preferences.confirm_destructive && self.preferences.confirm_destructive
{ {
self.mode = Mode::Confirm { self.mode = Mode::Confirm {
@@ -1000,7 +1003,7 @@ impl App {
} }
fn apply_discard(&mut self, ids: Vec<i64>, restore: bool) -> Result<()> { fn apply_discard(&mut self, ids: Vec<i64>, restore: bool) -> Result<()> {
let permanent = !restore && self.document_settings.trash_policy == "immediate"; let permanent = !restore && self.document_settings.trash_policy == TrashPolicy::Immediate;
self.db.discard(&ids, !restore)?; self.db.discard(&ids, !restore)?;
self.marked.clear(); self.marked.clear();
self.refresh()?; self.refresh()?;
@@ -1174,13 +1177,33 @@ impl App {
props.values[props.field].pop(); props.values[props.field].pop();
} }
KeyCode::Enter => { KeyCode::Enter => {
let priority = props.values[0].parse().unwrap_or(3); let parsed = (|| -> Result<_> {
let when_at = normalize_date(&self.db, &props.values[1])?; let priority = props.values[0]
let alarm_at = normalize_date(&self.db, &props.values[2])?; .parse::<i64>()
let numeric_value = if props.values[3].trim().is_empty() { .context("priority must be a number from 1 to 5")?;
None if !(1..=5).contains(&priority) {
} else { bail!("priority must be a number from 1 to 5");
props.values[3].parse().ok() }
let when_at = normalize_date(&self.db, &props.values[1])?;
let alarm_at = normalize_date(&self.db, &props.values[2])?;
let numeric_value = if props.values[3].trim().is_empty() {
None
} else {
Some(
props.values[3]
.parse::<f64>()
.context("numeric value must be a number")?,
)
};
Ok((priority, when_at, alarm_at, numeric_value))
})();
let (priority, when_at, alarm_at, numeric_value) = match parsed {
Ok(values) => values,
Err(error) => {
self.status = format!("Cannot save properties: {error:#}");
self.mode = Mode::Properties(props);
return Ok(());
}
}; };
self.db.update_item( self.db.update_item(
props.item_id, props.item_id,
@@ -1436,11 +1459,11 @@ impl App {
values: vec![ values: vec![
settings.description.clone(), settings.description.clone(),
yes_no(settings.backup_on_open), yes_no(settings.backup_on_open),
settings.trash_policy.clone(), settings.trash_policy.to_string(),
settings.done_policy.clone(), settings.done_policy.to_string(),
yes_no(settings.automatic_filing), yes_no(settings.automatic_filing),
settings.date_order.clone(), settings.date_order.to_string(),
settings.week_start.clone(), settings.week_start.to_string(),
settings.default_time.clone(), settings.default_time.clone(),
settings.morning_time.clone(), settings.morning_time.clone(),
settings.afternoon_time.clone(), settings.afternoon_time.clone(),
@@ -1471,7 +1494,7 @@ impl App {
vec![ vec![
c.name, c.name,
parent, parent,
c.kind, c.kind.to_string(),
c.match_text, c.match_text,
yes_no(c.exclusive), yes_no(c.exclusive),
condition, condition,
@@ -1502,10 +1525,10 @@ impl App {
let sections = v.sections_spec(); let sections = v.sections_spec();
vec![ vec![
v.name, v.name,
v.kind, v.kind.to_string(),
v.filter_value, v.filter_value,
v.filter_expr, v.filter_expr,
v.sort_key, v.sort_key.to_string(),
yes_no(v.show_done), yes_no(v.show_done),
columns, columns,
sections, sections,
@@ -1636,11 +1659,27 @@ impl App {
let settings = DocumentSettings { let settings = DocumentSettings {
description: form.values[0].trim().into(), description: form.values[0].trim().into(),
backup_on_open: parse_yes_no(&form.values[1])?, backup_on_open: parse_yes_no(&form.values[1])?,
trash_policy: form.values[2].trim().to_lowercase(), trash_policy: form.values[2]
done_policy: form.values[3].trim().to_lowercase(), .trim()
.to_lowercase()
.parse::<TrashPolicy>()
.map_err(anyhow::Error::msg)?,
done_policy: form.values[3]
.trim()
.to_lowercase()
.parse::<DonePolicy>()
.map_err(anyhow::Error::msg)?,
automatic_filing: parse_yes_no(&form.values[4])?, automatic_filing: parse_yes_no(&form.values[4])?,
date_order: form.values[5].trim().to_lowercase(), date_order: form.values[5]
week_start: form.values[6].trim().to_lowercase(), .trim()
.to_lowercase()
.parse::<DateOrder>()
.map_err(anyhow::Error::msg)?,
week_start: form.values[6]
.trim()
.to_lowercase()
.parse::<WeekStart>()
.map_err(anyhow::Error::msg)?,
default_time: form.values[7].trim().into(), default_time: form.values[7].trim().into(),
morning_time: form.values[8].trim().into(), morning_time: form.values[8].trim().into(),
afternoon_time: form.values[9].trim().into(), afternoon_time: form.values[9].trim().into(),
@@ -1707,7 +1746,7 @@ impl App {
let Some(item_id) = self.selected_item().map(|i| i.id) else { let Some(item_id) = self.selected_item().map(|i| i.id) else {
return Ok(()); return Ok(());
}; };
let Some(all) = self.views.iter().find(|v| v.kind == "list") else { let Some(all) = self.views.iter().find(|v| v.kind == ViewKind::List) else {
return Ok(()); return Ok(());
}; };
let choices = self let choices = self
@@ -1963,7 +2002,9 @@ fn normalize_date(db: &Database, value: &str) -> Result<Option<String>> {
} else if chrono::DateTime::parse_from_rfc3339(s).is_ok() { } else if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
Ok(Some(s.into())) Ok(Some(s.into()))
} else { } else {
db.interpret_date(&format!("on {s}")) Ok(Some(db.interpret_date(&format!("on {s}"))?.with_context(
|| format!("unrecognized date or time {s:?}"),
)?))
} }
} }
fn inside(r: Rect, x: u16, y: u16) -> bool { fn inside(r: Rect, x: u16, y: u16) -> bool {
@@ -2235,4 +2276,38 @@ mod tests {
assert!(matches!(input.kind, InputKind::Note(_))); assert!(matches!(input.kind, InputKind::Note(_)));
assert_eq!(input.value, "x"); assert_eq!(input.value, "x");
} }
#[test]
fn invalid_properties_remain_open_without_overwriting_the_item() {
let directory = tempdir().unwrap();
let path = directory.path().join("invalid-properties.agnd");
let mut db = Database::open(&path).unwrap();
let item_id = db.add_item("Keep my properties").unwrap();
let mut app = App::new_with_preferences(
db,
path,
AppPreferences::default(),
directory.path().join("preferences.toml"),
)
.unwrap();
app.mode = Mode::Properties(PropsState {
item_id,
values: [
"not-a-priority".into(),
"not-a-date".into(),
String::new(),
"not-a-number".into(),
String::new(),
],
field: 0,
});
app.handle_key(key(KeyCode::Enter)).unwrap();
assert!(matches!(app.mode, Mode::Properties(_)));
assert!(app.status.starts_with("Cannot save properties:"));
assert_eq!(app.items[0].priority, 3);
assert!(app.items[0].when_at.is_none());
assert!(app.items[0].numeric_value.is_none());
}
} }

242
src/db.rs
View File

@@ -1,6 +1,8 @@
use std::{ use std::{
fs, fmt::Display,
fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
@@ -9,15 +11,35 @@ use rusqlite::{Connection, OptionalExtension, params};
use crate::filter; use crate::filter;
use crate::model::{ use crate::model::{
Category, CategoryRule, DocumentSettings, Item, ItemChanges, MacroDef, ViewColumn, ViewDef, Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef,
ViewSection, RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection,
};
use crate::parser::{
DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence,
}; };
use crate::parser::{extract_when_configured, next_occurrence};
mod presets; mod presets;
pub use presets::Preset; pub use presets::Preset;
fn enum_column<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T>
where
T: FromStr,
T::Err: Display,
{
let value: String = row.get(index)?;
value.parse::<T>().map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
index,
rusqlite::types::Type::Text,
Box::new(io::Error::new(
io::ErrorKind::InvalidData,
error.to_string(),
)),
)
})
}
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
path: PathBuf, path: PathBuf,
@@ -295,12 +317,24 @@ impl Database {
let settings = DocumentSettings { let settings = DocumentSettings {
description: self.meta_value("document.description", &defaults.description)?, description: self.meta_value("document.description", &defaults.description)?,
backup_on_open: self.meta_bool("document.backup_on_open", defaults.backup_on_open)?, backup_on_open: self.meta_bool("document.backup_on_open", defaults.backup_on_open)?,
trash_policy: self.meta_value("document.trash_policy", &defaults.trash_policy)?, trash_policy: self
done_policy: self.meta_value("document.done_policy", &defaults.done_policy)?, .meta_value("document.trash_policy", defaults.trash_policy.as_str())?
.parse()
.map_err(anyhow::Error::msg)?,
done_policy: self
.meta_value("document.done_policy", defaults.done_policy.as_str())?
.parse()
.map_err(anyhow::Error::msg)?,
automatic_filing: self automatic_filing: self
.meta_bool("document.automatic_filing", defaults.automatic_filing)?, .meta_bool("document.automatic_filing", defaults.automatic_filing)?,
date_order: self.meta_value("document.date_order", &defaults.date_order)?, date_order: self
week_start: self.meta_value("document.week_start", &defaults.week_start)?, .meta_value("document.date_order", defaults.date_order.as_str())?
.parse()
.map_err(anyhow::Error::msg)?,
week_start: self
.meta_value("document.week_start", defaults.week_start.as_str())?
.parse()
.map_err(anyhow::Error::msg)?,
default_time: self.meta_value("document.default_time", &defaults.default_time)?, default_time: self.meta_value("document.default_time", &defaults.default_time)?,
morning_time: self.meta_value("document.morning_time", &defaults.morning_time)?, morning_time: self.meta_value("document.morning_time", &defaults.morning_time)?,
afternoon_time: self.meta_value("document.afternoon_time", &defaults.afternoon_time)?, afternoon_time: self.meta_value("document.afternoon_time", &defaults.afternoon_time)?,
@@ -325,14 +359,14 @@ impl Database {
"document.backup_on_open", "document.backup_on_open",
settings.backup_on_open.to_string(), settings.backup_on_open.to_string(),
), ),
("document.trash_policy", settings.trash_policy.clone()), ("document.trash_policy", settings.trash_policy.to_string()),
("document.done_policy", settings.done_policy.clone()), ("document.done_policy", settings.done_policy.to_string()),
( (
"document.automatic_filing", "document.automatic_filing",
settings.automatic_filing.to_string(), settings.automatic_filing.to_string(),
), ),
("document.date_order", settings.date_order.clone()), ("document.date_order", settings.date_order.to_string()),
("document.week_start", settings.week_start.clone()), ("document.week_start", settings.week_start.to_string()),
("document.default_time", settings.default_time.clone()), ("document.default_time", settings.default_time.clone()),
("document.morning_time", settings.morning_time.clone()), ("document.morning_time", settings.morning_time.clone()),
("document.afternoon_time", settings.afternoon_time.clone()), ("document.afternoon_time", settings.afternoon_time.clone()),
@@ -370,12 +404,14 @@ impl Database {
let settings = self.document_settings()?; let settings = self.document_settings()?;
Ok(extract_when_configured( Ok(extract_when_configured(
text, text,
&settings.date_order, DateParseConfig {
&settings.week_start, date_order: settings.date_order,
&settings.default_time, week_start: settings.week_start,
&settings.morning_time, default_time: &settings.default_time,
&settings.afternoon_time, morning_time: &settings.morning_time,
&settings.evening_time, afternoon_time: &settings.afternoon_time,
evening_time: &settings.evening_time,
},
)) ))
} }
@@ -405,9 +441,9 @@ impl Database {
Ok(ViewDef { Ok(ViewDef {
id: r.get(0)?, id: r.get(0)?,
name: r.get(1)?, name: r.get(1)?,
kind: r.get(2)?, kind: enum_column(r, 2)?,
filter_value: r.get(3)?, filter_value: r.get(3)?,
sort_key: r.get(4)?, sort_key: enum_column(r, 4)?,
show_done: r.get::<_, i64>(5)? != 0, show_done: r.get::<_, i64>(5)? != 0,
filter_expr: r.get(6)?, filter_expr: r.get(6)?,
columns: vec![], columns: vec![],
@@ -431,7 +467,7 @@ impl Database {
field: r.get(0)?, field: r.get(0)?,
heading: r.get(1)?, heading: r.get(1)?,
width: r.get::<_, u16>(2)?, width: r.get::<_, u16>(2)?,
aggregate: r.get(3)?, aggregate: enum_column(r, 3)?,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -468,7 +504,7 @@ impl Database {
id: r.get(0)?, id: r.get(0)?,
name: r.get(1)?, name: r.get(1)?,
parent_id: r.get(2)?, parent_id: r.get(2)?,
kind: r.get(3)?, kind: enum_column(r, 3)?,
match_text: r.get(4)?, match_text: r.get(4)?,
exclusive: r.get::<_, i64>(5)? != 0, exclusive: r.get::<_, i64>(5)? != 0,
}) })
@@ -532,7 +568,7 @@ impl Database {
id: r.get(0)?, id: r.get(0)?,
category_id: r.get(1)?, category_id: r.get(1)?,
condition_expr: r.get(2)?, condition_expr: r.get(2)?,
action_kind: r.get(3)?, action_kind: enum_column(r, 3)?,
action_value: r.get(4)?, action_value: r.get(4)?,
enabled: r.get::<_, i64>(5)? != 0, enabled: r.get::<_, i64>(5)? != 0,
}) })
@@ -560,7 +596,7 @@ impl Database {
"DELETE FROM category_rules WHERE category_id=?1", "DELETE FROM category_rules WHERE category_id=?1",
[category_id], [category_id],
)?; )?;
tx.execute("INSERT INTO category_rules(category_id,condition_expr,action_kind,action_value,enabled,sort_order) VALUES(?1,?2,?3,?4,1,0)",params![category_id,condition_expr.trim(),kind,value])?; tx.execute("INSERT INTO category_rules(category_id,condition_expr,action_kind,action_value,enabled,sort_order) VALUES(?1,?2,?3,?4,1,0)",params![category_id,condition_expr.trim(),kind.as_str(),value])?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -607,7 +643,7 @@ impl Database {
}; };
tx.execute("DELETE FROM view_columns WHERE view_id=?1", [view_id])?; tx.execute("DELETE FROM view_columns WHERE view_id=?1", [view_id])?;
for (order, column) in columns.iter().enumerate() { for (order, column) in columns.iter().enumerate() {
tx.execute("INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order) VALUES(?1,?2,?3,?4,?5,?6)",params![view_id,column.field,column.heading,column.width,column.aggregate,order as i64])?; tx.execute("INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order) VALUES(?1,?2,?3,?4,?5,?6)",params![view_id,column.field,column.heading,column.width,column.aggregate.as_str(),order as i64])?;
} }
tx.execute("DELETE FROM view_sections WHERE view_id=?1", [view_id])?; tx.execute("DELETE FROM view_sections WHERE view_id=?1", [view_id])?;
for (order, section) in sections.iter().enumerate() { for (order, section) in sections.iter().enumerate() {
@@ -843,7 +879,7 @@ impl Database {
id: r.get(0)?, id: r.get(0)?,
category_id: r.get(1)?, category_id: r.get(1)?,
condition_expr: r.get(2)?, condition_expr: r.get(2)?,
action_kind: r.get(3)?, action_kind: enum_column(r, 3)?,
action_value: r.get(4)?, action_value: r.get(4)?,
enabled: r.get::<_, i64>(5)? != 0, enabled: r.get::<_, i64>(5)? != 0,
}) })
@@ -906,8 +942,8 @@ impl Database {
} }
fn apply_rule(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result<bool> { fn apply_rule(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result<bool> {
match rule.action_kind.as_str() { match rule.action_kind {
"assign" | "exclude" => { RuleActionKind::Assign | RuleActionKind::Exclude => {
let target = if rule.action_value.trim().is_empty() { let target = if rule.action_value.trim().is_empty() {
Some(rule.category_id) Some(rule.category_id)
} else { } else {
@@ -923,7 +959,7 @@ impl Database {
return Ok(false); return Ok(false);
}; };
let state:Option<String>=self.conn.query_row("SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2",params![item_id,target],|r|r.get(0)).optional()?; let state:Option<String>=self.conn.query_row("SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2",params![item_id,target],|r|r.get(0)).optional()?;
if rule.action_kind == "assign" { if rule.action_kind == RuleActionKind::Assign {
if state.is_none() { if state.is_none() {
self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'conditional')",params![item_id,target])?; self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'conditional')",params![item_id,target])?;
return Ok(true); return Ok(true);
@@ -936,7 +972,7 @@ impl Database {
} }
Ok(false) Ok(false)
} }
"priority" => { RuleActionKind::Priority => {
let value = rule.action_value.parse::<i64>()?.clamp(1, 5); let value = rule.action_value.parse::<i64>()?.clamp(1, 5);
if item.priority != value { if item.priority != value {
self.conn.execute( self.conn.execute(
@@ -948,7 +984,7 @@ impl Database {
Ok(false) Ok(false)
} }
} }
"value" => { RuleActionKind::Value => {
let value = rule.action_value.parse::<f64>()?; let value = rule.action_value.parse::<f64>()?;
if item.numeric_value != Some(value) { if item.numeric_value != Some(value) {
self.conn.execute( self.conn.execute(
@@ -960,7 +996,7 @@ impl Database {
Ok(false) Ok(false)
} }
} }
"when" => { RuleActionKind::When => {
let value = self let value = self
.interpret_date(&rule.action_value)? .interpret_date(&rule.action_value)?
.context("rule action has no recognizable date")?; .context("rule action has no recognizable date")?;
@@ -974,7 +1010,7 @@ impl Database {
Ok(false) Ok(false)
} }
} }
"alarm" => { RuleActionKind::Alarm => {
let value = self let value = self
.interpret_date(&rule.action_value)? .interpret_date(&rule.action_value)?
.context("rule action has no recognizable alarm date")?; .context("rule action has no recognizable alarm date")?;
@@ -988,7 +1024,7 @@ impl Database {
Ok(false) Ok(false)
} }
} }
"repeat" => { RuleActionKind::Repeat => {
let value = rule.action_value.trim(); let value = rule.action_value.trim();
if let Some(when) = item.when_at.as_deref() { if let Some(when) = item.when_at.as_deref() {
next_occurrence(when, value)?; next_occurrence(when, value)?;
@@ -1003,7 +1039,7 @@ impl Database {
Ok(false) Ok(false)
} }
} }
"done" => { RuleActionKind::Done => {
let done = parse_bool(&rule.action_value); let done = parse_bool(&rule.action_value);
if item.done_at.is_some() != done { if item.done_at.is_some() != done {
let value = if done { let value = if done {
@@ -1020,7 +1056,6 @@ impl Database {
Ok(false) Ok(false)
} }
} }
_ => bail!("unsupported rule action {}", rule.action_kind),
} }
} }
@@ -1063,7 +1098,8 @@ impl Database {
tx.execute("INSERT OR REPLACE INTO item_categories(item_id,category_id,assignment) SELECT ?1,category_id,assignment FROM item_categories WHERE item_id=?2 AND assignment IN ('explicit','excluded')",params![created,id])?; tx.execute("INSERT OR REPLACE INTO item_categories(item_id,category_id,assignment) SELECT ?1,category_id,assignment FROM item_categories WHERE item_id=?2 AND assignment IN ('explicit','excluded')",params![created,id])?;
next_id = Some(created); next_id = Some(created);
} }
let discard_completed = item.done_at.is_none() && settings.done_policy == "trash"; let discard_completed =
item.done_at.is_none() && settings.done_policy == DonePolicy::Trash;
tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,discarded=CASE WHEN ?3 THEN 1 ELSE discarded END,updated_at=?1 WHERE id=?2",params![now,id,discard_completed])?; tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,discarded=CASE WHEN ?3 THEN 1 ELSE discarded END,updated_at=?1 WHERE id=?2",params![now,id,discard_completed])?;
tx.commit()?; tx.commit()?;
if let Some(next_id) = next_id if let Some(next_id) = next_id
@@ -1076,7 +1112,8 @@ impl Database {
} }
pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> { pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> {
let permanent = discarded && self.document_settings()?.trash_policy == "immediate"; let permanent =
discarded && self.document_settings()?.trash_policy == TrashPolicy::Immediate;
for id in ids { for id in ids {
if permanent { if permanent {
self.conn.execute("DELETE FROM items WHERE id=?1", [id])?; self.conn.execute("DELETE FROM items WHERE id=?1", [id])?;
@@ -1147,34 +1184,34 @@ impl Database {
"SELECT DISTINCT i.id,i.text,i.note,i.priority,i.when_at,i.done_at,i.alarm_at,i.numeric_value,i.created_at,i.updated_at,i.discarded,i.recurrence FROM items i", "SELECT DISTINCT i.id,i.text,i.note,i.priority,i.when_at,i.done_at,i.alarm_at,i.numeric_value,i.created_at,i.updated_at,i.discarded,i.recurrence FROM items i",
); );
let mut values: Vec<String> = vec![]; let mut values: Vec<String> = vec![];
if view.kind == "category" { if view.kind == ViewKind::Category {
sql.push_str( sql.push_str(
" JOIN item_categories ic ON ic.item_id=i.id AND ic.assignment<>'excluded'", " JOIN item_categories ic ON ic.item_id=i.id AND ic.assignment<>'excluded'",
); );
} }
sql.push_str(if view.kind == "trash" { sql.push_str(if view.kind == ViewKind::Trash {
" WHERE i.discarded=1" " WHERE i.discarded=1"
} else { } else {
" WHERE i.discarded=0" " WHERE i.discarded=0"
}); });
match view.kind.as_str() { match view.kind {
"category" => { ViewKind::Category => {
sql.push_str(" AND ic.category_id IN (WITH RECURSIVE descendants(id) AS (SELECT id FROM categories WHERE name=? UNION ALL SELECT c.id FROM categories c JOIN descendants d ON c.parent_id=d.id) SELECT id FROM descendants) AND NOT EXISTS(SELECT 1 FROM item_categories ex JOIN categories ec ON ec.id=ex.category_id WHERE ex.item_id=i.id AND ex.assignment='excluded' AND ec.name=?)"); sql.push_str(" AND ic.category_id IN (WITH RECURSIVE descendants(id) AS (SELECT id FROM categories WHERE name=? UNION ALL SELECT c.id FROM categories c JOIN descendants d ON c.parent_id=d.id) SELECT id FROM descendants) AND NOT EXISTS(SELECT 1 FROM item_categories ex JOIN categories ec ON ec.id=ex.category_id WHERE ex.item_id=i.id AND ex.assignment='excluded' AND ec.name=?)");
values.extend([view.filter_value.clone(), view.filter_value.clone()]); values.extend([view.filter_value.clone(), view.filter_value.clone()]);
} }
"upcoming" | "datebook" => { ViewKind::Upcoming | ViewKind::Datebook => {
let days = view.filter_value.parse::<i64>().unwrap_or(30); let days = view.filter_value.parse::<i64>().unwrap_or(30);
sql.push_str(" AND i.when_at IS NOT NULL AND i.when_at <= ?"); sql.push_str(" AND i.when_at IS NOT NULL AND i.when_at <= ?");
values.push((Local::now() + Duration::days(days)).to_rfc3339()); values.push((Local::now() + Duration::days(days)).to_rfc3339());
} }
"done" => { ViewKind::Done => {
let days = view.filter_value.parse::<i64>().unwrap_or(14); let days = view.filter_value.parse::<i64>().unwrap_or(14);
sql.push_str(" AND i.done_at IS NOT NULL AND i.done_at >= ?"); sql.push_str(" AND i.done_at IS NOT NULL AND i.done_at >= ?");
values.push((Local::now() - Duration::days(days)).to_rfc3339()); values.push((Local::now() - Duration::days(days)).to_rfc3339());
} }
_ => {} ViewKind::List | ViewKind::Trash => {}
} }
if !view.show_done && view.kind != "done" { if !view.show_done && view.kind != ViewKind::Done {
sql.push_str(" AND i.done_at IS NULL"); sql.push_str(" AND i.done_at IS NULL");
} }
if !search.is_empty() { if !search.is_empty() {
@@ -1182,12 +1219,12 @@ impl Database {
let like = format!("%{search}%"); let like = format!("%{search}%");
values.extend([like.clone(), like]); values.extend([like.clone(), like]);
} }
sql.push_str(match view.sort_key.as_str() { sql.push_str(match view.sort_key {
"when" => " ORDER BY i.when_at IS NULL,i.when_at,i.priority,i.sort_order", SortKey::When => " ORDER BY i.when_at IS NULL,i.when_at,i.priority,i.sort_order",
"done" => " ORDER BY i.done_at DESC,i.sort_order", SortKey::Done => " ORDER BY i.done_at DESC,i.sort_order",
"priority" => " ORDER BY i.priority,i.when_at IS NULL,i.when_at", SortKey::Priority => " ORDER BY i.priority,i.when_at IS NULL,i.when_at",
"updated" => " ORDER BY i.updated_at DESC", SortKey::Updated => " ORDER BY i.updated_at DESC",
_ => " ORDER BY i.sort_order,i.id", SortKey::Manual => " ORDER BY i.sort_order,i.id",
}); });
let mut stmt = self.conn.prepare(&sql)?; let mut stmt = self.conn.prepare(&sql)?;
let refs: Vec<&dyn rusqlite::ToSql> = let refs: Vec<&dyn rusqlite::ToSql> =
@@ -1247,7 +1284,7 @@ impl Database {
} }
pub fn close_maintenance(&self) -> Result<()> { pub fn close_maintenance(&self) -> Result<()> {
if self.document_settings()?.trash_policy == "on-close" { if self.document_settings()?.trash_policy == TrashPolicy::OnClose {
self.empty_trash()?; self.empty_trash()?;
} }
self.checkpoint() self.checkpoint()
@@ -1306,7 +1343,10 @@ impl Database {
"SUMMARY" => record.summary = value, "SUMMARY" => record.summary = value,
"DESCRIPTION" => record.note = value, "DESCRIPTION" => record.note = value,
"DTSTART" | "DUE" => record.when_at = parse_ical_date(&value), "DTSTART" | "DUE" => record.when_at = parse_ical_date(&value),
"COMPLETED" => record.done = true, "COMPLETED" => {
record.done = true;
record.completed_at = parse_ical_date(&value);
}
"STATUS" if value.eq_ignore_ascii_case("COMPLETED") => { "STATUS" if value.eq_ignore_ascii_case("COMPLETED") => {
record.done = true record.done = true
} }
@@ -1353,7 +1393,13 @@ impl Database {
} }
} }
if record.done { if record.done {
self.toggle_done(&[id])?; let completed_at = record
.completed_at
.unwrap_or_else(|| Local::now().to_rfc3339());
self.conn.execute(
"UPDATE items SET done_at=?1,updated_at=?1 WHERE id=?2",
params![completed_at, id],
)?;
} }
count += 1; count += 1;
} }
@@ -1436,6 +1482,7 @@ struct IcalRecord {
note: String, note: String,
when_at: Option<String>, when_at: Option<String>,
done: bool, done: bool,
completed_at: Option<String>,
priority: Option<i64>, priority: Option<i64>,
categories: Vec<String>, categories: Vec<String>,
recurrence: String, recurrence: String,
@@ -1461,10 +1508,13 @@ fn parse_ical_date(value: &str) -> Option<String> {
.map(|d| d.to_rfc3339()) .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()?; let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?.naive_local();
let old = DateTime::parse_from_rfc3339(old_when?).ok()?; let old = DateTime::parse_from_rfc3339(old_when?).ok()?.naive_local();
let new = DateTime::parse_from_rfc3339(new_when).ok()?; let new = DateTime::parse_from_rfc3339(new_when).ok()?.naive_local();
Some((new + (alarm - old)).to_rfc3339()) Local
.from_local_datetime(&(new + (alarm - old)))
.earliest()
.map(|date_time| date_time.to_rfc3339())
} }
fn ical_unescape(value: &str) -> String { fn ical_unescape(value: &str) -> String {
value value
@@ -1493,8 +1543,10 @@ fn recurrence_to_rrule(value: &str) -> Option<String> {
"monthly" => "FREQ=MONTHLY".into(), "monthly" => "FREQ=MONTHLY".into(),
"yearly" | "annually" => "FREQ=YEARLY".into(), "yearly" | "annually" => "FREQ=YEARLY".into(),
_ => { _ => {
let re = regex::Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap(); let c = RECURRENCE_INTERVAL.captures(&value)?;
let c = re.captures(&value)?; if c[1].parse::<u32>().ok()? == 0 {
return None;
}
let freq = match &c[2] { let freq = match &c[2] {
"day" | "days" => "DAILY", "day" | "days" => "DAILY",
"week" | "weeks" => "WEEKLY", "week" | "weeks" => "WEEKLY",
@@ -1710,28 +1762,34 @@ fn default_columns() -> Vec<ViewColumn> {
field: field.into(), field: field.into(),
heading: heading.into(), heading: heading.into(),
width, width,
aggregate: "none".into(), aggregate: Aggregate::None,
}) })
.collect() .collect()
} }
fn parse_action_spec(spec: &str) -> Result<(String, String)> { fn parse_action_spec(spec: &str) -> Result<(RuleActionKind, String)> {
let (kind, value) = spec.trim().split_once(':').unwrap_or((spec.trim(), "")); let (kind, value) = spec.trim().split_once(':').unwrap_or((spec.trim(), ""));
let kind = kind.trim().to_lowercase(); let kind: RuleActionKind = kind
if !matches!( .trim()
kind.as_str(), .to_lowercase()
"assign" | "exclude" | "priority" | "value" | "when" | "alarm" | "repeat" | "done" .parse()
) { .map_err(anyhow::Error::msg)
bail!("rule action must be assign, exclude, priority, value, when, alarm, repeat, or done") .context(
} "rule action must be assign, exclude, priority, value, when, alarm, repeat, or done",
)?;
if matches!( if matches!(
kind.as_str(), kind,
"priority" | "value" | "when" | "alarm" | "repeat" | "done" RuleActionKind::Priority
| RuleActionKind::Value
| RuleActionKind::When
| RuleActionKind::Alarm
| RuleActionKind::Repeat
| RuleActionKind::Done
) && value.trim().is_empty() ) && value.trim().is_empty()
{ {
bail!("rule action {kind} needs a value") bail!("rule action {kind} needs a value")
} }
if kind == "repeat" { if kind == RuleActionKind::Repeat {
next_occurrence("2026-01-05T09:00:00+00:00", value.trim())?; next_occurrence("2026-01-05T09:00:00+00:00", value.trim())?;
} }
Ok((kind, value.trim().into())) Ok((kind, value.trim().into()))
@@ -1745,21 +1803,6 @@ fn parse_bool(value: &str) -> bool {
} }
fn validate_document_settings(settings: &DocumentSettings) -> Result<()> { fn validate_document_settings(settings: &DocumentSettings) -> Result<()> {
if !matches!(
settings.trash_policy.as_str(),
"on-demand" | "on-close" | "end-of-day" | "immediate"
) {
bail!("trash policy must be on-demand, on-close, end-of-day, or immediate")
}
if !matches!(settings.done_policy.as_str(), "keep" | "trash") {
bail!("done policy must be keep or trash")
}
if !matches!(settings.date_order.as_str(), "ymd" | "mdy" | "dmy") {
bail!("date order must be ymd, mdy, or dmy")
}
if !matches!(settings.week_start.as_str(), "monday" | "sunday") {
bail!("week start must be monday or sunday")
}
for (name, value) in [ for (name, value) in [
("default time", &settings.default_time), ("default time", &settings.default_time),
("morning time", &settings.morning_time), ("morning time", &settings.morning_time),
@@ -1809,16 +1852,12 @@ fn parse_columns_spec(spec: &str) -> Result<Vec<ViewColumn>> {
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| default_heading(&field).into()); .unwrap_or_else(|| default_heading(&field).into());
let aggregate = parts let aggregate: Aggregate = parts
.get(3) .get(3)
.map(|s| s.trim().to_lowercase()) .map(|s| s.trim().to_lowercase())
.unwrap_or_else(|| "none".into()); .unwrap_or_else(|| "none".into())
if !matches!( .parse()
aggregate.as_str(), .map_err(anyhow::Error::msg)?;
"none" | "sum" | "avg" | "count" | "min" | "max"
) {
bail!("unsupported aggregate {aggregate}")
}
columns.push(ViewColumn { columns.push(ViewColumn {
field, field,
heading, heading,
@@ -2008,7 +2047,7 @@ mod tests {
.find(|v| v.id == id) .find(|v| v.id == id)
.unwrap(); .unwrap();
assert_eq!(view.columns[1].heading, "Cost"); assert_eq!(view.columns[1].heading, "Cost");
assert_eq!(view.columns[1].aggregate, "sum"); assert_eq!(view.columns[1].aggregate, Aggregate::Sum);
assert_eq!(view.sections.len(), 2); assert_eq!(view.sections.len(), 2);
assert!(view.sections[1].collapsed); assert!(view.sections[1].collapsed);
assert_eq!(db.items(&view, "").unwrap().len(), 1); assert_eq!(db.items(&view, "").unwrap().len(), 1);
@@ -2158,11 +2197,11 @@ mod tests {
db.save_document_settings(&DocumentSettings { db.save_document_settings(&DocumentSettings {
description: "European planning file".into(), description: "European planning file".into(),
backup_on_open: true, backup_on_open: true,
trash_policy: "immediate".into(), trash_policy: TrashPolicy::Immediate,
done_policy: "trash".into(), done_policy: DonePolicy::Trash,
automatic_filing: false, automatic_filing: false,
date_order: "dmy".into(), date_order: crate::model::DateOrder::DayMonthYear,
week_start: "sunday".into(), week_start: crate::model::WeekStart::Sunday,
default_time: "08:30".into(), default_time: "08:30".into(),
morning_time: "08:00".into(), morning_time: "08:00".into(),
afternoon_time: "14:00".into(), afternoon_time: "14:00".into(),
@@ -2257,6 +2296,7 @@ mod tests {
.find(|v| v.name == "All Items") .find(|v| v.name == "All Items")
.unwrap(); .unwrap();
let items = target.items(&all, "").unwrap(); let items = target.items(&all, "").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "Prepare launch"); assert_eq!(items[0].text, "Prepare launch");
assert_eq!(items[0].note, "Bring charts"); assert_eq!(items[0].note, "Bring charts");
assert_eq!(items[0].priority, 1); assert_eq!(items[0].priority, 1);

View File

@@ -289,7 +289,7 @@ impl Database {
column.field, column.field,
column.heading, column.heading,
column.width, column.width,
column.aggregate, column.aggregate.as_str(),
column_order as i64 column_order as i64
], ],
)?; )?;
@@ -318,7 +318,7 @@ impl Database {
params![ params![
category_id, category_id,
preset_rule.condition, preset_rule.condition,
action_kind, action_kind.as_str(),
action_value, action_value,
order as i64 order as i64
], ],

View File

@@ -1,5 +1,5 @@
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use chrono::{DateTime, Duration, Local}; use chrono::{DateTime, Duration, Local, TimeZone};
use crate::model::Item; use crate::model::Item;
@@ -99,7 +99,11 @@ fn predicate(p: &Predicate, item: &Item) -> bool {
fn parse_date(value: &str) -> Option<DateTime<Local>> { fn parse_date(value: &str) -> Option<DateTime<Local>> {
DateTime::parse_from_rfc3339(value) DateTime::parse_from_rfc3339(value)
.ok() .ok()
.map(|d| d.with_timezone(&Local)) .and_then(|date_time| {
Local
.from_local_datetime(&date_time.naive_local())
.earliest()
})
} }
fn compare_i64(a: i64, op: Compare, b: i64) -> bool { fn compare_i64(a: i64, op: Compare, b: i64) -> bool {
match op { match op {
@@ -346,7 +350,7 @@ impl Parser {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::Category; use crate::model::{Category, CategoryKind};
fn item() -> Item { fn item() -> Item {
Item { Item {
id: 1, id: 1,
@@ -365,7 +369,7 @@ mod tests {
id: 1, id: 1,
name: "Work".into(), name: "Work".into(),
parent_id: None, parent_id: None,
kind: "standard".into(), kind: CategoryKind::Standard,
match_text: String::new(), match_text: String::new(),
exclusive: false, exclusive: false,
}], }],
@@ -387,4 +391,13 @@ mod tests {
assert!(parse("priority nope 3").is_err()); assert!(parse("priority nope 3").is_err());
assert!(parse("(done or open").is_err()); assert!(parse("(done or open").is_err());
} }
#[test]
fn dates_are_interpreted_as_local_wall_time() {
let date = parse_date("2026-08-14T09:00:00+00:00").unwrap();
assert_eq!(
date.format("%Y-%m-%d %H:%M").to_string(),
"2026-08-14 09:00"
);
}
} }

View File

@@ -1,3 +1,5 @@
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Item { pub struct Item {
pub id: i64, pub id: i64,
@@ -30,7 +32,7 @@ pub struct Category {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub parent_id: Option<i64>, pub parent_id: Option<i64>,
pub kind: String, pub kind: CategoryKind,
pub match_text: String, pub match_text: String,
pub exclusive: bool, pub exclusive: bool,
} }
@@ -39,9 +41,9 @@ pub struct Category {
pub struct ViewDef { pub struct ViewDef {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub kind: String, pub kind: ViewKind,
pub filter_value: String, pub filter_value: String,
pub sort_key: String, pub sort_key: SortKey,
pub show_done: bool, pub show_done: bool,
pub filter_expr: String, pub filter_expr: String,
pub columns: Vec<ViewColumn>, pub columns: Vec<ViewColumn>,
@@ -53,7 +55,7 @@ pub struct ViewColumn {
pub field: String, pub field: String,
pub heading: String, pub heading: String,
pub width: u16, pub width: u16,
pub aggregate: String, pub aggregate: Aggregate,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -93,7 +95,7 @@ pub struct CategoryRule {
pub id: i64, pub id: i64,
pub category_id: i64, pub category_id: i64,
pub condition_expr: String, pub condition_expr: String,
pub action_kind: String, pub action_kind: RuleActionKind,
pub action_value: String, pub action_value: String,
pub enabled: bool, pub enabled: bool,
} }
@@ -121,11 +123,11 @@ pub struct ItemChanges {
pub struct DocumentSettings { pub struct DocumentSettings {
pub description: String, pub description: String,
pub backup_on_open: bool, pub backup_on_open: bool,
pub trash_policy: String, pub trash_policy: TrashPolicy,
pub done_policy: String, pub done_policy: DonePolicy,
pub automatic_filing: bool, pub automatic_filing: bool,
pub date_order: String, pub date_order: DateOrder,
pub week_start: String, pub week_start: WeekStart,
pub default_time: String, pub default_time: String,
pub morning_time: String, pub morning_time: String,
pub afternoon_time: String, pub afternoon_time: String,
@@ -138,11 +140,11 @@ impl Default for DocumentSettings {
Self { Self {
description: String::new(), description: String::new(),
backup_on_open: false, backup_on_open: false,
trash_policy: "on-demand".into(), trash_policy: TrashPolicy::OnDemand,
done_policy: "keep".into(), done_policy: DonePolicy::Keep,
automatic_filing: true, automatic_filing: true,
date_order: "ymd".into(), date_order: DateOrder::YearMonthDay,
week_start: "monday".into(), week_start: WeekStart::Monday,
default_time: "09:00".into(), default_time: "09:00".into(),
morning_time: "09:00".into(), morning_time: "09:00".into(),
afternoon_time: "13:00".into(), afternoon_time: "13:00".into(),
@@ -151,3 +153,102 @@ impl Default for DocumentSettings {
} }
} }
} }
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",
});

View File

@@ -1,78 +1,93 @@
use anyhow::{Result, bail}; use std::sync::LazyLock;
use anyhow::{Context, Result, bail};
use chrono::{ use chrono::{
Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday, Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday,
}; };
use regex::Regex; 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)] #[cfg(test)]
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> { pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
extract_when_from_configured( extract_when_from_configured(text, now, DateParseConfig::default())
text, now, "ymd", "monday", "09:00", "09:00", "13:00", "18:00",
)
} }
#[allow(clippy::too_many_arguments)] impl Default for DateParseConfig<'static> {
pub fn extract_when_configured( fn default() -> Self {
text: &str, Self {
date_order: &str, date_order: DateOrder::YearMonthDay,
week_start: &str, week_start: WeekStart::Monday,
default_time: &str, default_time: "09:00",
morning_time: &str, morning_time: "09:00",
afternoon_time: &str, afternoon_time: "13:00",
evening_time: &str, evening_time: "18:00",
) -> Option<String> { }
extract_when_from_configured( }
text, }
Local::now().naive_local(),
date_order, pub fn extract_when_configured(text: &str, config: DateParseConfig<'_>) -> Option<String> {
week_start, extract_when_from_configured(text, Local::now().naive_local(), config)
default_time,
morning_time,
afternoon_time,
evening_time,
)
} }
#[allow(clippy::too_many_arguments)]
fn extract_when_from_configured( fn extract_when_from_configured(
text: &str, text: &str,
now: NaiveDateTime, now: NaiveDateTime,
date_order: &str, config: DateParseConfig<'_>,
week_start: &str,
default_time: &str,
morning_time: &str,
afternoon_time: &str,
evening_time: &str,
) -> Option<String> { ) -> Option<String> {
let lower = text.to_lowercase(); let lower = text.to_lowercase();
let mut date = None; let mut date = None;
let iso = Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").unwrap(); if let Some(c) = ISO_DATE.captures(&lower) {
if let Some(c) = iso.captures(&lower) {
date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?); date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?);
} }
if date.is_none() { if date.is_none()
let numeric = Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").unwrap(); && let Some(c) = NUMERIC_DATE.captures(&lower)
if let Some(c) = numeric.captures(&lower) { {
let values = [ let values = [
c[1].parse::<i32>().ok()?, c[1].parse::<i32>().ok()?,
c[2].parse::<i32>().ok()?, c[2].parse::<i32>().ok()?,
c[3].parse::<i32>().ok()?, c[3].parse::<i32>().ok()?,
]; ];
let (year, month, day) = match date_order { let (year, month, day) = match config.date_order {
"mdy" => (values[2], values[0], values[1]), DateOrder::MonthDayYear => (values[2], values[0], values[1]),
"dmy" => (values[2], values[1], values[0]), DateOrder::DayMonthYear => (values[2], values[1], values[0]),
_ => (values[0], values[1], values[2]), DateOrder::YearMonthDay => (values[0], values[1], values[2]),
}; };
let year = if year < 100 { year + 2000 } else { year }; let year = if year < 100 { year + 2000 } else { year };
date = NaiveDate::from_ymd_opt(year, month as u32, day as u32); date = NaiveDate::from_ymd_opt(year, month as u32, day as u32);
}
} }
if date.is_none() { if date.is_none() {
let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap(); if let Some(c) = IN_DAYS.captures(&lower) {
if let Some(c) = in_days.captures(&lower) {
date = Some(now.date() + Duration::days(c[1].parse().ok()?)); date = Some(now.date() + Duration::days(c[1].parse().ok()?));
} else if lower.contains("day after tomorrow") { } else if lower.contains("day after tomorrow") {
date = Some(now.date() + Duration::days(2)); date = Some(now.date() + Duration::days(2));
@@ -84,7 +99,7 @@ fn extract_when_from_configured(
} }
if date.is_none() && (lower.contains("this week") || lower.contains("next week")) { if date.is_none() && (lower.contains("this week") || lower.contains("next week")) {
let day_from_start = if week_start == "sunday" { let day_from_start = if config.week_start == WeekStart::Sunday {
now.weekday().num_days_from_sunday() as i64 now.weekday().num_days_from_sunday() as i64
} else { } else {
now.weekday().num_days_from_monday() as i64 now.weekday().num_days_from_monday() as i64
@@ -126,9 +141,7 @@ fn extract_when_from_configured(
} }
let date = date?; let date = date?;
let time_re = Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").unwrap(); let time = if let Some(c) = TIME_12H.captures(&lower) {
let time24_re = Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").unwrap();
let time = if let Some(c) = time_re.captures(&lower) {
let mut hour: u32 = c[1].parse().ok()?; let mut hour: u32 = c[1].parse().ok()?;
let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?; let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?;
if hour == 12 { if hour == 12 {
@@ -138,19 +151,19 @@ fn extract_when_from_configured(
hour += 12; hour += 12;
} }
NaiveTime::from_hms_opt(hour, minute, 0)? NaiveTime::from_hms_opt(hour, minute, 0)?
} else if let Some(c) = time24_re.captures(&lower) { } else if let Some(c) = TIME_24H.captures(&lower) {
NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)? NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)?
} else if lower.contains("morning") { } else if lower.contains("morning") {
parse_clock(morning_time)? parse_clock(config.morning_time)?
} else if lower.contains("afternoon") { } else if lower.contains("afternoon") {
parse_clock(afternoon_time)? parse_clock(config.afternoon_time)?
} else if lower.contains("evening") || lower.contains("tonight") { } else if lower.contains("evening") || lower.contains("tonight") {
parse_clock(evening_time)? parse_clock(config.evening_time)?
} else { } else {
parse_clock(default_time)? parse_clock(config.default_time)?
}; };
let local = Local.from_local_datetime(&date.and_time(time)).single()?; let local = Local.from_local_datetime(&date.and_time(time)).earliest()?;
Some(local.to_rfc3339()) Some(local.to_rfc3339())
} }
@@ -181,7 +194,9 @@ pub fn format_when(value: Option<&str>, date_format: &str, clock_24h: bool) -> S
} }
pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> { pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> {
let current = chrono::DateTime::parse_from_rfc3339(current)?.with_timezone(&Local); // 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 rule = recurrence.trim().to_lowercase();
let next = match rule.as_str() { let next = match rule.as_str() {
"daily" => current + Duration::days(1), "daily" => current + Duration::days(1),
@@ -200,13 +215,15 @@ pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> {
.checked_add_months(Months::new(12)) .checked_add_months(Months::new(12))
.ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?, .ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?,
_ => { _ => {
let re = Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap(); let Some(c) = RECURRENCE_INTERVAL.captures(&rule) else {
let Some(c) = re.captures(&rule) else {
bail!( bail!(
"recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'" "recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'"
) )
}; };
let n: u32 = c[1].parse()?; let n: u32 = c[1].parse()?;
if n == 0 {
bail!("recurrence interval must be greater than zero");
}
match &c[2] { match &c[2] {
"day" | "days" => current + Duration::days(n as i64), "day" | "days" => current + Duration::days(n as i64),
"week" | "weeks" => current + Duration::weeks(n as i64), "week" | "weeks" => current + Duration::weeks(n as i64),
@@ -219,6 +236,10 @@ pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> {
} }
} }
}; };
let next = Local
.from_local_datetime(&next)
.earliest()
.context("next occurrence does not exist in the local timezone")?;
Ok(next.to_rfc3339()) Ok(next.to_rfc3339())
} }
@@ -266,17 +287,17 @@ mod tests {
#[test] #[test]
fn honors_document_date_and_named_time_settings() { fn honors_document_date_and_named_time_settings() {
let got = extract_when_from_configured( let config = DateParseConfig {
"Planning on 18/08/2026 in the evening", date_order: DateOrder::DayMonthYear,
base(), week_start: WeekStart::Monday,
"dmy", default_time: "08:30",
"monday", morning_time: "08:00",
"08:30", afternoon_time: "14:00",
"08:00", evening_time: "19:15",
"14:00", };
"19:15", let got =
) extract_when_from_configured("Planning on 18/08/2026 in the evening", base(), config)
.unwrap(); .unwrap();
assert!(got.contains("2026-08-18T19:15:00")); assert!(got.contains("2026-08-18T19:15:00"));
assert_eq!( assert_eq!(
format_when(Some(&got), "european", false), format_when(Some(&got), "european", false),
@@ -285,14 +306,19 @@ mod tests {
let next_week = extract_when_from_configured( let next_week = extract_when_from_configured(
"Review next week", "Review next week",
base(), base(),
"ymd", DateParseConfig {
"sunday", week_start: WeekStart::Sunday,
"09:00", ..DateParseConfig::default()
"09:00", },
"13:00",
"18:00",
) )
.unwrap(); .unwrap();
assert!(next_week.contains("2026-08-23T09:00:00")); assert!(next_week.contains("2026-08-23T09:00:00"));
} }
#[test]
fn recurrence_uses_the_written_wall_time_and_rejects_zero_intervals() {
let next = next_occurrence("2026-08-14T09:00:00+00:00", "daily").unwrap();
assert!(next.contains("2026-08-15T09:00:00"));
assert!(next_occurrence("2026-08-14T09:00:00+00:00", "every 0 days").is_err());
}
} }

View File

@@ -12,7 +12,7 @@ use ratatui::{
use crate::{ use crate::{
app::{App, FormKind, InputKind, Mode, form_choices}, app::{App, FormKind, InputKind, Mode, form_choices},
filter, filter,
model::{Item, ViewColumn}, model::{Aggregate, Item, ViewColumn, ViewKind},
parser::format_when, parser::format_when,
}; };
@@ -232,7 +232,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
); );
return; return;
} }
if app.current_view().kind == "datebook" { if app.current_view().kind == ViewKind::Datebook {
draw_datebook(frame, app, area); draw_datebook(frame, app, area);
return; return;
} }
@@ -261,7 +261,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
for index in 0..app.items.len() { for index in 0..app.items.len() {
display.push(DisplayLine::Item(index)); display.push(DisplayLine::Item(index));
} }
if columns.iter().any(|c| c.aggregate != "none") { if columns.iter().any(|c| c.aggregate != Aggregate::None) {
display.push(DisplayLine::Aggregate((0..app.items.len()).collect())); display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
} }
} else { } else {
@@ -285,7 +285,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
for index in &indices { for index in &indices {
display.push(DisplayLine::Item(*index)); display.push(DisplayLine::Item(*index));
} }
if columns.iter().any(|c| c.aggregate != "none") { if columns.iter().any(|c| c.aggregate != Aggregate::None) {
display.push(DisplayLine::Aggregate(indices)); display.push(DisplayLine::Aggregate(indices));
} }
} }
@@ -448,10 +448,10 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
} }
} }
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String { fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
if column.aggregate == "none" { if column.aggregate == Aggregate::None {
return String::new(); return String::new();
} }
if column.aggregate == "count" { if column.aggregate == Aggregate::Count {
return format!("count {}", indices.len()); return format!("count {}", indices.len());
} }
let values = indices let values = indices
@@ -465,12 +465,12 @@ fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app:
if values.is_empty() { if values.is_empty() {
return String::new(); return String::new();
} }
let value = match column.aggregate.as_str() { let value = match column.aggregate {
"sum" => values.iter().sum(), Aggregate::Sum => values.iter().sum(),
"avg" => values.iter().sum::<f64>() / values.len() as f64, Aggregate::Average => values.iter().sum::<f64>() / values.len() as f64,
"min" => values.iter().copied().fold(f64::INFINITY, f64::min), Aggregate::Minimum => values.iter().copied().fold(f64::INFINITY, f64::min),
"max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), Aggregate::Maximum => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
_ => return String::new(), Aggregate::None | Aggregate::Count => return String::new(),
}; };
format!("{} {}", column.aggregate, format_number(value, app)) format!("{} {}", column.aggregate, format_number(value, app))
} }
@@ -1787,7 +1787,7 @@ mod tests {
let mut db = Database::open(&path).unwrap(); let mut db = Database::open(&path).unwrap();
let mut settings = db.document_settings().unwrap(); let mut settings = db.document_settings().unwrap();
settings.note_tab_width = 3; settings.note_tab_width = 3;
settings.trash_policy = "immediate".into(); settings.trash_policy = crate::model::TrashPolicy::Immediate;
db.save_document_settings(&settings).unwrap(); db.save_document_settings(&settings).unwrap();
db.add_item("Disposable item").unwrap(); db.add_item("Disposable item").unwrap();
let mut app = App::new_with_preferences( let mut app = App::new_with_preferences(