preferences added
This commit is contained in:
304
src/db.rs
304
src/db.rs
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user