preferences added

This commit is contained in:
Hermes Agent
2026-08-16 20:08:27 +00:00
parent b099157a17
commit f53ed36f31
11 changed files with 1425 additions and 173 deletions

304
src/db.rs
View File

@@ -1,27 +1,40 @@
use std::{fs, path::Path};
use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use rusqlite::{Connection, OptionalExtension, params};
use crate::filter;
use crate::model::{Category, CategoryRule, Item, ItemChanges, ViewColumn, ViewDef, ViewSection};
use crate::parser::{extract_when, next_occurrence};
use crate::model::{
Category, CategoryRule, DocumentSettings, Item, ItemChanges, ViewColumn, ViewDef, ViewSection,
};
use crate::parser::{extract_when_configured, next_occurrence};
pub struct Database {
conn: Connection,
path: PathBuf,
}
impl Database {
pub fn open(path: &Path) -> Result<Self> {
let existed = path.exists() && path.metadata().is_ok_and(|metadata| metadata.len() > 0);
let conn =
Connection::open(path).with_context(|| format!("could not open {}", path.display()))?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
let mut db = Self { conn };
let mut db = Self {
conn,
path: path.to_owned(),
};
db.migrate()?;
db.seed_defaults()?;
if existed && db.document_settings()?.backup_on_open {
db.backup_now()?;
}
Ok(db)
}
@@ -178,6 +191,114 @@ impl Database {
Ok(())
}
pub fn document_settings(&self) -> Result<DocumentSettings> {
let defaults = DocumentSettings::default();
let settings = DocumentSettings {
description: self.meta_value("document.description", &defaults.description)?,
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)?,
done_policy: self.meta_value("document.done_policy", &defaults.done_policy)?,
automatic_filing: self
.meta_bool("document.automatic_filing", defaults.automatic_filing)?,
date_order: self.meta_value("document.date_order", &defaults.date_order)?,
week_start: self.meta_value("document.week_start", &defaults.week_start)?,
default_time: self.meta_value("document.default_time", &defaults.default_time)?,
morning_time: self.meta_value("document.morning_time", &defaults.morning_time)?,
afternoon_time: self.meta_value("document.afternoon_time", &defaults.afternoon_time)?,
evening_time: self.meta_value("document.evening_time", &defaults.evening_time)?,
note_tab_width: self
.meta_value(
"document.note_tab_width",
&defaults.note_tab_width.to_string(),
)?
.parse()?,
};
validate_document_settings(&settings)?;
Ok(settings)
}
pub fn save_document_settings(&mut self, settings: &DocumentSettings) -> Result<()> {
validate_document_settings(settings)?;
let tx = self.conn.transaction()?;
for (key, value) in [
("document.description", settings.description.clone()),
(
"document.backup_on_open",
settings.backup_on_open.to_string(),
),
("document.trash_policy", settings.trash_policy.clone()),
("document.done_policy", settings.done_policy.clone()),
(
"document.automatic_filing",
settings.automatic_filing.to_string(),
),
("document.date_order", settings.date_order.clone()),
("document.week_start", settings.week_start.clone()),
("document.default_time", settings.default_time.clone()),
("document.morning_time", settings.morning_time.clone()),
("document.afternoon_time", settings.afternoon_time.clone()),
("document.evening_time", settings.evening_time.clone()),
(
"document.note_tab_width",
settings.note_tab_width.to_string(),
),
] {
tx.execute(
"INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
params![key, value],
)?;
}
tx.commit()?;
self.reapply_automatic_assignments()?;
Ok(())
}
fn meta_value(&self, key: &str, default: &str) -> Result<String> {
Ok(self
.conn
.query_row("SELECT value FROM meta WHERE key=?1", [key], |row| {
row.get(0)
})
.optional()?
.unwrap_or_else(|| default.to_owned()))
}
fn meta_bool(&self, key: &str, default: bool) -> Result<bool> {
Ok(parse_bool(&self.meta_value(key, &default.to_string())?))
}
pub fn interpret_date(&self, text: &str) -> Result<Option<String>> {
let settings = self.document_settings()?;
Ok(extract_when_configured(
text,
&settings.date_order,
&settings.week_start,
&settings.default_time,
&settings.morning_time,
&settings.afternoon_time,
&settings.evening_time,
))
}
pub fn backup_path(&self) -> PathBuf {
let mut path = self.path.as_os_str().to_os_string();
path.push(".bak");
PathBuf::from(path)
}
pub fn backup_now(&self) -> Result<PathBuf> {
self.conn.execute_batch("PRAGMA wal_checkpoint(FULL)")?;
let destination = self.backup_path();
fs::copy(&self.path, &destination).with_context(|| {
format!(
"could not back up {} to {}",
self.path.display(),
destination.display()
)
})?;
Ok(destination)
}
pub fn views(&self) -> Result<Vec<ViewDef>> {
let mut stmt = self.conn.prepare("SELECT id,name,kind,filter_value,sort_key,show_done,filter_expr FROM views ORDER BY sort_order,name")?;
let mut views = stmt
@@ -481,13 +602,18 @@ impl Database {
bail!("item text cannot be empty");
}
let now = Local::now().to_rfc3339();
let when_at = extract_when(text);
let settings = self.document_settings()?;
let when_at = self.interpret_date(text)?;
let tx = self.conn.transaction()?;
tx.execute("INSERT INTO items(text,when_at,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?3,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))", params![text,when_at,now])?;
let id = tx.last_insert_rowid();
Self::auto_assign_tx(&tx, id, text)?;
if settings.automatic_filing {
Self::auto_assign_tx(&tx, id, text)?;
}
tx.commit()?;
self.apply_rules_to_item(id)?;
if settings.automatic_filing {
self.apply_rules_to_item(id)?;
}
Ok(id)
}
@@ -516,6 +642,7 @@ impl Database {
}
pub fn reapply_automatic_assignments(&mut self) -> Result<()> {
let automatic_filing = self.document_settings()?.automatic_filing;
let tx = self.conn.transaction()?;
let items: Vec<(i64, String)> = {
let mut stmt = tx.prepare("SELECT id,text FROM items WHERE discarded=0")?;
@@ -523,14 +650,24 @@ impl Database {
.collect::<rusqlite::Result<_>>()?
};
for (id, text) in items {
Self::auto_assign_tx(&tx, id, &text)?;
if automatic_filing {
Self::auto_assign_tx(&tx, id, &text)?;
} else {
tx.execute(
"DELETE FROM item_categories WHERE item_id=?1 AND assignment='automatic'",
[id],
)?;
}
}
tx.commit()?;
self.apply_rules_to_all()?;
if automatic_filing {
self.apply_rules_to_all()?;
}
Ok(())
}
pub fn update_item(&mut self, id: i64, changes: &ItemChanges) -> Result<()> {
let automatic_filing = self.document_settings()?.automatic_filing;
let tx = self.conn.transaction()?;
if let Some(text) = &changes.text {
if text.trim().is_empty() {
@@ -540,7 +677,9 @@ impl Database {
"UPDATE items SET text=?1,updated_at=?2 WHERE id=?3",
params![text.trim(), Local::now().to_rfc3339(), id],
)?;
Self::auto_assign_tx(&tx, id, text)?;
if automatic_filing {
Self::auto_assign_tx(&tx, id, text)?;
}
}
if let Some(note) = &changes.note {
tx.execute(
@@ -591,7 +730,9 @@ impl Database {
)?;
}
tx.commit()?;
self.apply_rules_to_item(id)?;
if automatic_filing {
self.apply_rules_to_item(id)?;
}
Ok(())
}
@@ -721,7 +862,8 @@ impl Database {
}
}
"when" => {
let value = extract_when(&rule.action_value)
let value = self
.interpret_date(&rule.action_value)?
.context("rule action has no recognizable date")?;
if item.when_at.as_deref() != Some(value.as_str()) {
self.conn.execute(
@@ -734,7 +876,8 @@ impl Database {
}
}
"alarm" => {
let value = extract_when(&rule.action_value)
let value = self
.interpret_date(&rule.action_value)?
.context("rule action has no recognizable alarm date")?;
if item.alarm_at.as_deref() != Some(value.as_str()) {
self.conn.execute(
@@ -791,6 +934,7 @@ impl Database {
}
pub fn toggle_done(&mut self, ids: &[i64]) -> Result<()> {
let settings = self.document_settings()?;
for id in ids {
let Some(item) = self.load_item(*id)? else {
continue;
@@ -814,13 +958,18 @@ impl Database {
);
tx.execute("INSERT INTO items(text,note,priority,when_at,alarm_at,numeric_value,recurrence,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))",params![&item.text,&item.note,item.priority,next_when,next_alarm,item.numeric_value,&item.recurrence,&now])?;
let created = tx.last_insert_rowid();
Self::auto_assign_tx(&tx, created, &item.text)?;
if settings.automatic_filing {
Self::auto_assign_tx(&tx, created, &item.text)?;
}
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);
}
tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,updated_at=?1 WHERE id=?2",params![now,id])?;
let discard_completed = item.done_at.is_none() && settings.done_policy == "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.commit()?;
if let Some(next_id) = next_id {
if let Some(next_id) = next_id
&& settings.automatic_filing
{
self.apply_rules_to_item(next_id)?;
}
}
@@ -828,11 +977,16 @@ impl Database {
}
pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> {
let permanent = discarded && self.document_settings()?.trash_policy == "immediate";
for id in ids {
self.conn.execute(
"UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3",
params![discarded as i64, Local::now().to_rfc3339(), id],
)?;
if permanent {
self.conn.execute("DELETE FROM items WHERE id=?1", [id])?;
} else {
self.conn.execute(
"UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3",
params![discarded as i64, Local::now().to_rfc3339(), id],
)?;
}
}
Ok(())
}
@@ -987,6 +1141,19 @@ impl Database {
Ok(())
}
pub fn empty_trash(&self) -> Result<usize> {
Ok(self
.conn
.execute("DELETE FROM items WHERE discarded=1", [])?)
}
pub fn close_maintenance(&self) -> Result<()> {
if self.document_settings()?.trash_policy == "on-close" {
self.empty_trash()?;
}
self.checkpoint()
}
pub fn import_path(&mut self, path: &Path) -> Result<usize> {
if path
.extension()
@@ -1478,6 +1645,37 @@ fn parse_bool(value: &str) -> bool {
)
}
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 [
("default time", &settings.default_time),
("morning time", &settings.morning_time),
("afternoon time", &settings.afternoon_time),
("evening time", &settings.evening_time),
] {
NaiveTime::parse_from_str(value, "%H:%M")
.with_context(|| format!("{name} must use 24-hour HH:MM format"))?;
}
if !(1..=16).contains(&settings.note_tab_width) {
bail!("note tab width must be between 1 and 16")
}
Ok(())
}
fn parse_columns_spec(spec: &str) -> Result<Vec<ViewColumn>> {
if spec.trim().is_empty() {
return Ok(default_columns());
@@ -1852,6 +2050,70 @@ mod tests {
assert!(!html.contains("Omit report"));
}
#[test]
fn document_settings_persist_and_drive_document_behavior() {
let directory = tempdir().unwrap();
let path = directory.path().join("configured.agnd");
{
let mut db = Database::open(&path).unwrap();
db.save_document_settings(&DocumentSettings {
description: "European planning file".into(),
backup_on_open: true,
trash_policy: "immediate".into(),
done_policy: "trash".into(),
automatic_filing: false,
date_order: "dmy".into(),
week_start: "sunday".into(),
default_time: "08:30".into(),
morning_time: "08:00".into(),
afternoon_time: "14:00".into(),
evening_time: "19:15".into(),
note_tab_width: 8,
})
.unwrap();
}
let mut db = Database::open(&path).unwrap();
assert!(db.backup_path().exists());
let settings = db.document_settings().unwrap();
assert_eq!(settings.description, "European planning file");
assert_eq!(settings.note_tab_width, 8);
let item = db
.add_item("Call Ada on 18/08/2026 in the evening")
.unwrap();
let all = db
.views()
.unwrap()
.into_iter()
.find(|view| view.name == "All Items")
.unwrap();
let items = db.items(&all, "").unwrap();
assert!(
items[0]
.when_at
.as_deref()
.unwrap()
.contains("2026-08-18T19:15:00")
);
assert!(
!items[0]
.categories
.iter()
.any(|category| category.name == "Calls")
);
db.toggle_done(&[item]).unwrap();
let trash = db
.views()
.unwrap()
.into_iter()
.find(|view| view.name == "Trash")
.unwrap();
assert_eq!(db.items(&trash, "").unwrap().len(), 1);
db.discard(&[item], true).unwrap();
assert!(db.items(&trash, "").unwrap().is_empty());
}
#[test]
fn ical_roundtrip_preserves_planner_fields() {
let d = tempdir().unwrap();