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:
125
src/app.rs
125
src/app.rs
@@ -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
242
src/db.rs
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/model.rs
127
src/model.rs
@@ -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",
|
||||||
|
});
|
||||||
|
|||||||
188
src/parser.rs
188
src/parser.rs
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/ui.rs
26
src/ui.rs
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user