Files
RogueAgenda/src/db.rs
2026-08-16 20:08:27 +00:00

2176 lines
83 KiB
Rust

use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use rusqlite::{Connection, OptionalExtension, params};
use crate::filter;
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,
path: path.to_owned(),
};
db.migrate()?;
db.seed_defaults()?;
if existed && db.document_settings()?.backup_on_open {
db.backup_now()?;
}
Ok(db)
}
fn migrate(&mut self) -> Result<()> {
self.conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY, value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5),
when_at TEXT,
done_at TEXT,
alarm_at TEXT,
numeric_value REAL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
discarded INTEGER NOT NULL DEFAULT 0 CHECK(discarded IN (0,1))
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
kind TEXT NOT NULL DEFAULT 'standard',
match_text TEXT NOT NULL DEFAULT '',
exclusive INTEGER NOT NULL DEFAULT 0 CHECK(exclusive IN (0,1)),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS item_categories (
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
assignment TEXT NOT NULL DEFAULT 'explicit',
PRIMARY KEY(item_id, category_id)
);
CREATE TABLE IF NOT EXISTS dependencies (
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
prerequisite_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY(item_id, prerequisite_id),
CHECK(item_id <> prerequisite_id)
);
CREATE TABLE IF NOT EXISTS views (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
kind TEXT NOT NULL DEFAULT 'list',
filter_value TEXT NOT NULL DEFAULT '',
sort_key TEXT NOT NULL DEFAULT 'manual',
show_done INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_items_when ON items(when_at);
CREATE INDEX IF NOT EXISTS idx_items_done ON items(done_at);
CREATE INDEX IF NOT EXISTS idx_item_categories_category ON item_categories(category_id);
COMMIT;"
)?;
let version: i64 = self
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version < 2 {
let has_filter_expr = {
let mut stmt = self.conn.prepare("PRAGMA table_info(views)")?;
let names = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
names.iter().any(|name| name == "filter_expr")
};
if !has_filter_expr {
self.conn.execute_batch(
"ALTER TABLE views ADD COLUMN filter_expr TEXT NOT NULL DEFAULT '';",
)?;
}
}
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS view_columns (
id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE,
field TEXT NOT NULL, heading TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 20,
aggregate TEXT NOT NULL DEFAULT 'none', sort_order INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS view_sections (
id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE,
heading TEXT NOT NULL, filter_expr TEXT NOT NULL DEFAULT '', collapsed INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS category_rules (
id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
condition_expr TEXT NOT NULL, action_kind TEXT NOT NULL, action_value TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0);",
)?;
if version < 2 {
self.conn.pragma_update(None, "user_version", 2)?;
}
if version < 3 {
let has_recurrence = {
let mut stmt = self.conn.prepare("PRAGMA table_info(items)")?;
let names = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
names.iter().any(|name| name == "recurrence")
};
if !has_recurrence {
self.conn.execute_batch(
"ALTER TABLE items ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';",
)?;
}
self.conn.pragma_update(None, "user_version", 3)?;
}
Ok(())
}
fn seed_defaults(&mut self) -> Result<()> {
let tx = self.conn.transaction()?;
let count: i64 = tx.query_row("SELECT COUNT(*) FROM categories", [], |r| r.get(0))?;
if count == 0 {
tx.execute("INSERT INTO categories(name, match_text, sort_order) VALUES('Tasks', 'todo,to-do,task,call,meet,review,send,buy', 0)", [])?;
let tasks = tx.last_insert_rowid();
tx.execute("INSERT INTO categories(name, parent_id, match_text, sort_order) VALUES('Calls', ?1, 'call,phone,ring', 0)", [tasks])?;
tx.execute("INSERT INTO categories(name, parent_id, match_text, sort_order) VALUES('Meetings', ?1, 'meet,meeting,appointment', 1)", [tasks])?;
tx.execute(
"INSERT INTO categories(name, match_text, sort_order) VALUES('People', '', 1)",
[],
)?;
tx.execute(
"INSERT INTO categories(name, match_text, sort_order) VALUES('Projects', '', 2)",
[],
)?;
tx.execute(
"INSERT INTO categories(name, kind, sort_order) VALUES('When', 'date', 3)",
[],
)?;
tx.execute(
"INSERT INTO categories(name, kind, sort_order) VALUES('Priority', 'numeric', 4)",
[],
)?;
}
let views: i64 = tx.query_row("SELECT COUNT(*) FROM views", [], |r| r.get(0))?;
if views == 0 {
for (name, kind, filter, sort, done, order) in [
("All Items", "list", "", "manual", 1, 0),
("Tasks", "category", "Tasks", "when", 0, 1),
("Upcoming", "upcoming", "7", "when", 0, 2),
("Recently Done", "done", "14", "done", 1, 3),
("Datebook", "datebook", "30", "when", 1, 4),
("Trash", "trash", "", "updated", 1, 5),
] {
tx.execute("INSERT INTO views(name, kind, filter_value, sort_key, show_done, sort_order) VALUES(?1,?2,?3,?4,?5,?6)", params![name,kind,filter,sort,done,order])?;
}
}
tx.execute(
"INSERT OR IGNORE INTO meta(key,value) VALUES('title','Rogue Agenda')",
[],
)?;
tx.commit()?;
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
.query_map([], |r| {
Ok(ViewDef {
id: r.get(0)?,
name: r.get(1)?,
kind: r.get(2)?,
filter_value: r.get(3)?,
sort_key: r.get(4)?,
show_done: r.get::<_, i64>(5)? != 0,
filter_expr: r.get(6)?,
columns: vec![],
sections: vec![],
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
drop(stmt);
for view in &mut views {
view.columns = self.view_columns(view.id)?;
view.sections = self.view_sections(view.id)?;
}
Ok(views)
}
fn view_columns(&self, view_id: i64) -> Result<Vec<ViewColumn>> {
let mut stmt=self.conn.prepare("SELECT field,heading,width,aggregate FROM view_columns WHERE view_id=?1 ORDER BY sort_order,id")?;
let columns = stmt
.query_map([view_id], |r| {
Ok(ViewColumn {
field: r.get(0)?,
heading: r.get(1)?,
width: r.get::<_, u16>(2)?,
aggregate: r.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if columns.is_empty() {
Ok(default_columns())
} else {
Ok(columns)
}
}
fn view_sections(&self, view_id: i64) -> Result<Vec<ViewSection>> {
let mut stmt=self.conn.prepare("SELECT id,heading,filter_expr,collapsed FROM view_sections WHERE view_id=?1 ORDER BY sort_order,id")?;
Ok(stmt
.query_map([view_id], |r| {
Ok(ViewSection {
id: r.get(0)?,
heading: r.get(1)?,
filter_expr: r.get(2)?,
collapsed: r.get::<_, i64>(3)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub fn categories(&self) -> Result<Vec<Category>> {
let mut stmt = self.conn.prepare("SELECT id,name,parent_id,kind,match_text,exclusive FROM categories ORDER BY parent_id IS NOT NULL,parent_id,sort_order,name COLLATE NOCASE")?;
Ok(stmt
.query_map([], Self::category_row)?
.collect::<rusqlite::Result<_>>()?)
}
fn category_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Category> {
Ok(Category {
id: r.get(0)?,
name: r.get(1)?,
parent_id: r.get(2)?,
kind: r.get(3)?,
match_text: r.get(4)?,
exclusive: r.get::<_, i64>(5)? != 0,
})
}
pub fn save_category(
&self,
id: Option<i64>,
name: &str,
parent_id: Option<i64>,
kind: &str,
match_text: &str,
exclusive: bool,
) -> Result<i64> {
let name = name.trim();
if name.is_empty() {
bail!("category name cannot be empty");
}
if !matches!(kind, "standard" | "date" | "numeric") {
bail!("category kind must be standard, date, or numeric");
}
if id.is_some() && id == parent_id {
bail!("a category cannot be its own parent");
}
if let (Some(id), Some(parent)) = (id, parent_id) {
let cycle: bool = self.conn.query_row(
"WITH RECURSIVE descendants(id) AS (
SELECT id FROM categories WHERE parent_id=?1
UNION ALL SELECT c.id FROM categories c JOIN descendants d ON c.parent_id=d.id
) SELECT EXISTS(SELECT 1 FROM descendants WHERE id=?2)",
params![id, parent],
|r| r.get(0),
)?;
if cycle {
bail!("category hierarchy would contain a cycle");
}
}
if let Some(id) = id {
self.conn.execute(
"UPDATE categories SET name=?1,parent_id=?2,kind=?3,match_text=?4,exclusive=?5 WHERE id=?6",
params![name, parent_id, kind, match_text.trim(), exclusive as i64, id],
)?;
Ok(id)
} else {
self.conn.execute("INSERT INTO categories(name,parent_id,kind,match_text,exclusive,sort_order) VALUES(?1,?2,?3,?4,?5,(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories))",params![name,parent_id,kind,match_text.trim(),exclusive as i64])?;
Ok(self.conn.last_insert_rowid())
}
}
pub fn delete_category(&self, id: i64) -> Result<()> {
self.conn
.execute("DELETE FROM categories WHERE id=?1", [id])?;
Ok(())
}
pub fn category_rules(&self, category_id: i64) -> Result<Vec<CategoryRule>> {
let mut stmt=self.conn.prepare("SELECT id,category_id,condition_expr,action_kind,action_value,enabled FROM category_rules WHERE category_id=?1 ORDER BY sort_order,id")?;
Ok(stmt
.query_map([category_id], |r| {
Ok(CategoryRule {
id: r.get(0)?,
category_id: r.get(1)?,
condition_expr: r.get(2)?,
action_kind: r.get(3)?,
action_value: r.get(4)?,
enabled: r.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub fn save_primary_rule(
&self,
category_id: i64,
condition_expr: &str,
action_spec: &str,
) -> Result<()> {
if condition_expr.trim().is_empty() && action_spec.trim().is_empty() {
self.conn.execute(
"DELETE FROM category_rules WHERE category_id=?1",
[category_id],
)?;
return Ok(());
}
filter::parse(condition_expr).context("invalid category rule condition")?;
let (kind, value) = parse_action_spec(action_spec)?;
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM category_rules WHERE category_id=?1",
[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.commit()?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn save_view_design(
&mut self,
id: Option<i64>,
name: &str,
kind: &str,
filter_value: &str,
sort_key: &str,
show_done: bool,
filter_expr: &str,
columns_spec: &str,
sections_spec: &str,
) -> Result<i64> {
let name = name.trim();
if name.is_empty() {
bail!("view name cannot be empty");
}
if !matches!(
kind,
"list" | "category" | "upcoming" | "done" | "datebook" | "trash"
) {
bail!("unsupported view kind");
}
if !matches!(
sort_key,
"manual" | "when" | "done" | "priority" | "updated"
) {
bail!("unsupported view sort key");
}
filter::parse(filter_expr).context("invalid view filter")?;
let columns = parse_columns_spec(columns_spec)?;
let sections = parse_sections_spec(sections_spec)?;
let tx = self.conn.transaction()?;
let view_id = if let Some(id) = id {
tx.execute("UPDATE views SET name=?1,kind=?2,filter_value=?3,sort_key=?4,show_done=?5,filter_expr=?6 WHERE id=?7",params![name,kind,filter_value.trim(),sort_key,show_done as i64,filter_expr.trim(),id])?;
id
} else {
tx.execute("INSERT INTO views(name,kind,filter_value,sort_key,show_done,sort_order,filter_expr) VALUES(?1,?2,?3,?4,?5,(SELECT COALESCE(MAX(sort_order),0)+1 FROM views),?6)",params![name,kind,filter_value.trim(),sort_key,show_done as i64,filter_expr.trim()])?;
tx.last_insert_rowid()
};
tx.execute("DELETE FROM view_columns WHERE view_id=?1", [view_id])?;
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("DELETE FROM view_sections WHERE view_id=?1", [view_id])?;
for (order, section) in sections.iter().enumerate() {
tx.execute("INSERT INTO view_sections(view_id,heading,filter_expr,collapsed,sort_order) VALUES(?1,?2,?3,?4,?5)",params![view_id,section.heading,section.filter_expr,section.collapsed as i64,order as i64])?;
}
tx.commit()?;
Ok(view_id)
}
pub fn delete_view(&self, id: i64) -> Result<()> {
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM views", [], |r| r.get(0))?;
if count <= 1 {
bail!("the last view cannot be deleted");
}
self.conn.execute("DELETE FROM views WHERE id=?1", [id])?;
Ok(())
}
pub fn move_view(&self, id: i64, delta: i64) -> Result<()> {
let views = self.views()?;
let Some(index) = views.iter().position(|v| v.id == id) else {
return Ok(());
};
let target = (index as i64 + delta).clamp(0, views.len().saturating_sub(1) as i64) as usize;
if target == index {
return Ok(());
}
self.conn.execute(
"UPDATE views SET sort_order=?1 WHERE id=?2",
params![target as i64, views[index].id],
)?;
self.conn.execute(
"UPDATE views SET sort_order=?1 WHERE id=?2",
params![index as i64, views[target].id],
)?;
Ok(())
}
pub fn dependency_ids(&self, item_id: i64) -> Result<Vec<i64>> {
let mut stmt = self
.conn
.prepare("SELECT prerequisite_id FROM dependencies WHERE item_id=?1")?;
Ok(stmt
.query_map([item_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?)
}
pub fn toggle_dependency(&self, item_id: i64, prerequisite_id: i64) -> Result<()> {
if item_id == prerequisite_id {
bail!("an item cannot depend on itself");
}
let exists: bool = self.conn.query_row(
"SELECT EXISTS(SELECT 1 FROM dependencies WHERE item_id=?1 AND prerequisite_id=?2)",
params![item_id, prerequisite_id],
|r| r.get(0),
)?;
if exists {
self.conn.execute(
"DELETE FROM dependencies WHERE item_id=?1 AND prerequisite_id=?2",
params![item_id, prerequisite_id],
)?;
} else {
let cycle: bool = self.conn.query_row(
"WITH RECURSIVE reach(id) AS (
SELECT prerequisite_id FROM dependencies WHERE item_id=?1
UNION SELECT d.prerequisite_id FROM dependencies d JOIN reach r ON d.item_id=r.id
) SELECT EXISTS(SELECT 1 FROM reach WHERE id=?2)",
params![prerequisite_id, item_id],
|r| r.get(0),
)?;
if cycle {
bail!("dependency would contain a cycle");
}
self.conn.execute(
"INSERT INTO dependencies(item_id,prerequisite_id) VALUES(?1,?2)",
params![item_id, prerequisite_id],
)?;
}
Ok(())
}
pub fn unmet_dependencies(&self, item_id: i64) -> Result<i64> {
Ok(self.conn.query_row("SELECT COUNT(*) FROM dependencies d JOIN items p ON p.id=d.prerequisite_id WHERE d.item_id=?1 AND p.done_at IS NULL",[item_id],|r|r.get(0))?)
}
pub fn add_item(&mut self, text: &str) -> Result<i64> {
let text = text.trim();
if text.is_empty() {
bail!("item text cannot be empty");
}
let now = Local::now().to_rfc3339();
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();
if settings.automatic_filing {
Self::auto_assign_tx(&tx, id, text)?;
}
tx.commit()?;
if settings.automatic_filing {
self.apply_rules_to_item(id)?;
}
Ok(id)
}
fn auto_assign_tx(tx: &rusqlite::Transaction<'_>, item_id: i64, text: &str) -> Result<()> {
tx.execute(
"DELETE FROM item_categories WHERE item_id=?1 AND assignment='automatic'",
[item_id],
)?;
let lower = text.to_lowercase();
let mut stmt = tx.prepare("SELECT id,match_text FROM categories WHERE match_text <> ''")?;
let matches: Vec<(i64, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
for (cat, phrases) in matches {
if phrases
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.any(|p| lower.contains(&p.to_lowercase()))
{
tx.execute("INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'automatic')", params![item_id,cat])?;
}
}
Ok(())
}
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")?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?
};
for (id, text) in items {
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()?;
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() {
bail!("item text cannot be empty");
}
tx.execute(
"UPDATE items SET text=?1,updated_at=?2 WHERE id=?3",
params![text.trim(), Local::now().to_rfc3339(), id],
)?;
if automatic_filing {
Self::auto_assign_tx(&tx, id, text)?;
}
}
if let Some(note) = &changes.note {
tx.execute(
"UPDATE items SET note=?1,updated_at=?2 WHERE id=?3",
params![note, Local::now().to_rfc3339(), id],
)?;
}
if let Some(priority) = changes.priority {
tx.execute(
"UPDATE items SET priority=?1,updated_at=?2 WHERE id=?3",
params![priority.clamp(1, 5), Local::now().to_rfc3339(), id],
)?;
}
if let Some(when_at) = &changes.when_at {
tx.execute(
"UPDATE items SET when_at=?1,updated_at=?2 WHERE id=?3",
params![when_at, Local::now().to_rfc3339(), id],
)?;
}
if let Some(alarm_at) = &changes.alarm_at {
tx.execute(
"UPDATE items SET alarm_at=?1,updated_at=?2 WHERE id=?3",
params![alarm_at, Local::now().to_rfc3339(), id],
)?;
}
if let Some(value) = changes.numeric_value {
tx.execute(
"UPDATE items SET numeric_value=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), id],
)?;
}
if let Some(recurrence) = &changes.recurrence {
if !recurrence.trim().is_empty() {
let base = if let Some(when_at) = &changes.when_at {
when_at.clone()
} else {
tx.query_row("SELECT when_at FROM items WHERE id=?1", [id], |r| {
r.get::<_, Option<String>>(0)
})?
};
if let Some(base) = base {
next_occurrence(&base, recurrence)?;
}
}
tx.execute(
"UPDATE items SET recurrence=?1,updated_at=?2 WHERE id=?3",
params![recurrence.trim(), Local::now().to_rfc3339(), id],
)?;
}
tx.commit()?;
if automatic_filing {
self.apply_rules_to_item(id)?;
}
Ok(())
}
fn all_rules(&self) -> Result<Vec<CategoryRule>> {
let mut stmt=self.conn.prepare("SELECT id,category_id,condition_expr,action_kind,action_value,enabled FROM category_rules WHERE enabled=1 ORDER BY sort_order,id")?;
Ok(stmt
.query_map([], |r| {
Ok(CategoryRule {
id: r.get(0)?,
category_id: r.get(1)?,
condition_expr: r.get(2)?,
action_kind: r.get(3)?,
action_value: r.get(4)?,
enabled: r.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub fn apply_rules_to_all(&mut self) -> Result<()> {
let mut stmt = self
.conn
.prepare("SELECT id FROM items WHERE discarded=0")?;
let ids = stmt
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<i64>>>()?;
drop(stmt);
for id in ids {
self.apply_rules_to_item(id)?;
}
Ok(())
}
pub fn count_matching_filter(&self, source: &str) -> Result<usize> {
let expr = filter::parse(source)?;
let mut stmt = self
.conn
.prepare("SELECT id FROM items WHERE discarded=0")?;
let ids = stmt
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<i64>>>()?;
drop(stmt);
let mut count = 0;
for id in ids {
if let Some(item) = self.load_item(id)?
&& filter::evaluate(&expr, &item)
{
count += 1;
}
}
Ok(count)
}
fn apply_rules_to_item(&mut self, item_id: i64) -> Result<()> {
let rules = self.all_rules()?;
for _ in 0..16 {
let Some(item) = self.load_item(item_id)? else {
return Ok(());
};
let mut changed = false;
for rule in &rules {
let expr = filter::parse(&rule.condition_expr)?;
if filter::evaluate(&expr, &item) {
changed |= self.apply_rule(item_id, rule, &item)?;
}
}
if !changed {
return Ok(());
}
}
bail!("category rules did not converge after 16 passes")
}
fn apply_rule(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result<bool> {
match rule.action_kind.as_str() {
"assign" | "exclude" => {
let target = if rule.action_value.trim().is_empty() {
Some(rule.category_id)
} else {
self.conn
.query_row(
"SELECT id FROM categories WHERE name=?1",
[rule.action_value.trim()],
|r| r.get(0),
)
.optional()?
};
let Some(target) = target else {
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()?;
if rule.action_kind == "assign" {
if state.is_none() {
self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'conditional')",params![item_id,target])?;
return Ok(true);
}
} else if state.as_deref().is_none()
|| matches!(state.as_deref(), Some("automatic" | "conditional"))
{
self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'excluded') ON CONFLICT(item_id,category_id) DO UPDATE SET assignment='excluded'",params![item_id,target])?;
return Ok(true);
}
Ok(false)
}
"priority" => {
let value = rule.action_value.parse::<i64>()?.clamp(1, 5);
if item.priority != value {
self.conn.execute(
"UPDATE items SET priority=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
"value" => {
let value = rule.action_value.parse::<f64>()?;
if item.numeric_value != Some(value) {
self.conn.execute(
"UPDATE items SET numeric_value=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
"when" => {
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(
"UPDATE items SET when_at=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
"alarm" => {
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(
"UPDATE items SET alarm_at=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
"repeat" => {
let value = rule.action_value.trim();
if let Some(when) = item.when_at.as_deref() {
next_occurrence(when, value)?;
}
if item.recurrence != value {
self.conn.execute(
"UPDATE items SET recurrence=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
"done" => {
let done = parse_bool(&rule.action_value);
if item.done_at.is_some() != done {
let value = if done {
Some(Local::now().to_rfc3339())
} else {
None
};
self.conn.execute(
"UPDATE items SET done_at=?1,updated_at=?2 WHERE id=?3",
params![value, Local::now().to_rfc3339(), item_id],
)?;
Ok(true)
} else {
Ok(false)
}
}
_ => bail!("unsupported rule action {}", rule.action_kind),
}
}
fn load_item(&self, id: i64) -> Result<Option<Item>> {
let mut item=self.conn.query_row("SELECT id,text,note,priority,when_at,done_at,alarm_at,numeric_value,created_at,updated_at,discarded,recurrence FROM items WHERE id=?1",[id],|r|Ok(Item{id:r.get(0)?,text:r.get(1)?,note:r.get(2)?,priority:r.get(3)?,when_at:r.get(4)?,done_at:r.get(5)?,alarm_at:r.get(6)?,numeric_value:r.get(7)?,created_at:r.get(8)?,updated_at:r.get(9)?,discarded:r.get::<_,i64>(10)?!=0,recurrence:r.get(11)?,categories:vec![]})).optional()?;
if let Some(item) = &mut item {
item.categories = self.categories_for_item(id)?;
}
Ok(item)
}
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;
};
let now = Local::now().to_rfc3339();
let next = if item.done_at.is_none() && !item.recurrence.is_empty() {
item.when_at
.as_deref()
.map(|when| next_occurrence(when, &item.recurrence))
.transpose()?
} else {
None
};
let tx = self.conn.transaction()?;
let mut next_id = None;
if let Some(next_when) = next {
let next_alarm = shift_alarm(
item.alarm_at.as_deref(),
item.when_at.as_deref(),
&next_when,
);
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();
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);
}
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
&& settings.automatic_filing
{
self.apply_rules_to_item(next_id)?;
}
}
Ok(())
}
pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> {
let permanent = discarded && self.document_settings()?.trash_policy == "immediate";
for id in ids {
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(())
}
pub fn toggle_assignment(&mut self, item_id: i64, category_id: i64) -> Result<()> {
let state: Option<String> = self
.conn
.query_row(
"SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2",
params![item_id, category_id],
|r| r.get(0),
)
.optional()?;
match state.as_deref() {
Some("excluded") => {
self.conn.execute(
"DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2",
params![item_id, category_id],
)?;
}
Some(_) => {
self.conn.execute("UPDATE item_categories SET assignment='excluded' WHERE item_id=?1 AND category_id=?2", params![item_id, category_id])?;
}
None => {
let exclusive: bool = self.conn.query_row(
"SELECT exclusive FROM categories WHERE id=?1",
[category_id],
|r| r.get(0),
)?;
if exclusive {
self.conn.execute("DELETE FROM item_categories WHERE item_id=?1 AND category_id IN (SELECT sibling.id FROM categories chosen JOIN categories sibling ON sibling.parent_id IS chosen.parent_id AND sibling.exclusive=1 WHERE chosen.id=?2)", params![item_id,category_id])?;
}
self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'explicit')", params![item_id,category_id])?;
}
}
Ok(())
}
pub fn item_category_ids(&self, item_id: i64) -> Result<Vec<i64>> {
let mut stmt = self.conn.prepare(
"SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded'",
)?;
Ok(stmt
.query_map([item_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?)
}
pub fn excluded_category_ids(&self, item_id: i64) -> Result<Vec<i64>> {
let mut stmt = self.conn.prepare(
"SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment='excluded'",
)?;
Ok(stmt
.query_map([item_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?)
}
pub fn items(&self, view: &ViewDef, search: &str) -> Result<Vec<Item>> {
let mut sql = String::from(
"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![];
if view.kind == "category" {
sql.push_str(
" JOIN item_categories ic ON ic.item_id=i.id AND ic.assignment<>'excluded'",
);
}
sql.push_str(if view.kind == "trash" {
" WHERE i.discarded=1"
} else {
" WHERE i.discarded=0"
});
match view.kind.as_str() {
"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=?)");
values.extend([view.filter_value.clone(), view.filter_value.clone()]);
}
"upcoming" | "datebook" => {
let days = view.filter_value.parse::<i64>().unwrap_or(30);
sql.push_str(" AND i.when_at IS NOT NULL AND i.when_at <= ?");
values.push((Local::now() + Duration::days(days)).to_rfc3339());
}
"done" => {
let days = view.filter_value.parse::<i64>().unwrap_or(14);
sql.push_str(" AND i.done_at IS NOT NULL AND i.done_at >= ?");
values.push((Local::now() - Duration::days(days)).to_rfc3339());
}
_ => {}
}
if !view.show_done && view.kind != "done" {
sql.push_str(" AND i.done_at IS NULL");
}
if !search.is_empty() {
sql.push_str(" AND (i.text LIKE ? OR i.note LIKE ?)");
let like = format!("%{search}%");
values.extend([like.clone(), like]);
}
sql.push_str(match view.sort_key.as_str() {
"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",
"priority" => " ORDER BY i.priority,i.when_at IS NULL,i.when_at",
"updated" => " ORDER BY i.updated_at DESC",
_ => " ORDER BY i.sort_order,i.id",
});
let mut stmt = self.conn.prepare(&sql)?;
let refs: Vec<&dyn rusqlite::ToSql> =
values.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let rows = stmt.query_map(refs.as_slice(), |r| {
Ok(Item {
id: r.get(0)?,
text: r.get(1)?,
note: r.get(2)?,
priority: r.get(3)?,
when_at: r.get(4)?,
done_at: r.get(5)?,
alarm_at: r.get(6)?,
numeric_value: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
discarded: r.get::<_, i64>(10)? != 0,
recurrence: r.get(11)?,
categories: vec![],
})
})?;
let mut items: Vec<Item> = rows.collect::<rusqlite::Result<_>>()?;
for item in &mut items {
item.categories = self.categories_for_item(item.id)?;
}
if !view.filter_expr.trim().is_empty() {
let expr = filter::parse(&view.filter_expr).context("invalid saved view filter")?;
items.retain(|item| filter::evaluate(&expr, item));
}
Ok(items)
}
fn categories_for_item(&self, item_id: i64) -> Result<Vec<Category>> {
let mut stmt = self.conn.prepare("WITH RECURSIVE inherited(id) AS (
SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded'
UNION SELECT c.parent_id FROM categories c JOIN inherited i ON c.id=i.id WHERE c.parent_id IS NOT NULL
) SELECT DISTINCT c.id,c.name,c.parent_id,c.kind,c.match_text,c.exclusive FROM categories c JOIN inherited i ON i.id=c.id
WHERE c.id NOT IN (SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment='excluded') ORDER BY c.name")?;
Ok(stmt
.query_map([item_id], Self::category_row)?
.collect::<rusqlite::Result<_>>()?)
}
pub fn due_alarm_count(&self) -> Result<i64> {
Ok(self.conn.query_row("SELECT COUNT(*) FROM items WHERE discarded=0 AND done_at IS NULL AND alarm_at IS NOT NULL AND alarm_at <= ?1", [Local::now().to_rfc3339()], |r| r.get(0))?)
}
pub fn checkpoint(&self) -> Result<()> {
self.conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)")?;
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()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ics"))
{
return self.import_ical(path);
}
self.import_text(path)
}
fn import_text(&mut self, path: &Path) -> Result<usize> {
let text = fs::read_to_string(path)
.with_context(|| format!("could not read {}", path.display()))?;
let mut count = 0;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
self.add_item(line)?;
count += 1;
}
Ok(count)
}
fn import_ical(&mut self, path: &Path) -> Result<usize> {
let source = fs::read_to_string(path)
.with_context(|| format!("could not read {}", path.display()))?;
let mut lines: Vec<String> = vec![];
for raw in source.lines() {
let line = raw.trim_end_matches('\r');
if (line.starts_with(' ') || line.starts_with('\t')) && !lines.is_empty() {
lines.last_mut().unwrap().push_str(line.trim_start());
} else {
lines.push(line.to_owned());
}
}
let mut records = vec![];
let mut current: Option<IcalRecord> = None;
for line in lines {
match line.as_str() {
"BEGIN:VEVENT" | "BEGIN:VTODO" => current = Some(IcalRecord::default()),
"END:VEVENT" | "END:VTODO" => {
if let Some(record) = current.take() {
records.push(record)
}
}
_ => {
if let Some(record) = &mut current
&& let Some((raw_key, value)) = line.split_once(':')
{
let key = raw_key.split(';').next().unwrap_or(raw_key);
let value = ical_unescape(value);
match key {
"SUMMARY" => record.summary = value,
"DESCRIPTION" => record.note = value,
"DTSTART" | "DUE" => record.when_at = parse_ical_date(&value),
"COMPLETED" => record.done = true,
"STATUS" if value.eq_ignore_ascii_case("COMPLETED") => {
record.done = true
}
"PRIORITY" => record.priority = value.parse().ok(),
"CATEGORIES" => {
record.categories = value.split(',').map(ical_unescape).collect()
}
"RRULE" => record.recurrence = rrule_to_recurrence(&value),
_ => {}
}
}
}
}
}
let mut count = 0;
for record in records.into_iter().filter(|r| !r.summary.trim().is_empty()) {
let id = self.add_item(&record.summary)?;
self.update_item(
id,
&ItemChanges {
note: Some(record.note),
priority: record.priority.map(|p| p.clamp(1, 5)),
when_at: Some(record.when_at),
recurrence: Some(record.recurrence),
..Default::default()
},
)?;
for name in record
.categories
.into_iter()
.filter(|n| !n.trim().is_empty())
{
let category_id = if let Some(category) = self
.categories()?
.into_iter()
.find(|c| c.name.eq_ignore_ascii_case(&name))
{
category.id
} else {
self.save_category(None, &name, None, "standard", "", false)?
};
if !self.item_category_ids(id)?.contains(&category_id) {
self.toggle_assignment(id, category_id)?;
}
}
if record.done {
self.toggle_done(&[id])?;
}
count += 1;
}
Ok(count)
}
pub fn export(&self, format: &str, path: &Path, view_name: Option<&str>) -> Result<usize> {
let views = self.views()?;
let view = if let Some(name) = view_name {
views
.into_iter()
.find(|v| v.name.eq_ignore_ascii_case(name))
.with_context(|| format!("view {name:?} does not exist"))?
} else {
views
.into_iter()
.find(|v| v.name == "All Items")
.context("All Items view is missing")?
};
let items = self.items(&view, "")?;
let out = if format.eq_ignore_ascii_case("json") {
let records = items.iter().map(|i| format!(" {{\"id\":{},\"text\":{},\"note\":{},\"priority\":{},\"when\":{},\"done\":{},\"alarm\":{},\"value\":{},\"recurrence\":{},\"categories\":{}}}", i.id,json(&i.text),json(&i.note),i.priority,opt_json(i.when_at.as_deref()),opt_json(i.done_at.as_deref()),opt_json(i.alarm_at.as_deref()),i.numeric_value.map(|v|v.to_string()).unwrap_or_else(||"null".into()),json(&i.recurrence),json(&i.category_names()))).collect::<Vec<_>>().join(",\n");
format!("[\n{records}\n]\n")
} else if format.eq_ignore_ascii_case("csv") {
let mut s =
String::from("id,text,note,priority,when,done,alarm,value,recurrence,categories\n");
for i in &items {
s.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{}\n",
i.id,
csv(&i.text),
csv(&i.note),
i.priority,
csv(i.when_at.as_deref().unwrap_or("")),
csv(i.done_at.as_deref().unwrap_or("")),
csv(i.alarm_at.as_deref().unwrap_or("")),
i.numeric_value.map(|v| v.to_string()).unwrap_or_default(),
csv(&i.recurrence),
csv(&i.category_names())
));
}
s
} else if matches!(format.to_lowercase().as_str(), "md" | "markdown") {
render_markdown(&view, &items)
} else if format.eq_ignore_ascii_case("html") {
render_html(&view, &items)
} else if matches!(format.to_lowercase().as_str(), "ics" | "ical") {
render_ical(&items)
} else {
bail!("export format must be csv, json, markdown, html, or ics");
};
fs::write(path, out).with_context(|| format!("could not write {}", path.display()))?;
Ok(items.len())
}
pub fn seed_demo(&mut self) -> Result<()> {
let exists: Option<i64> = self
.conn
.query_row("SELECT id FROM items LIMIT 1", [], |r| r.get(0))
.optional()?;
if exists.is_some() {
return Ok(());
}
for text in [
"Call Sarah tomorrow at 3pm to review the Wells proposal",
"Meet with Anne next Tuesday at 9am to discuss her progress",
"Review quarterly budget in 3 days",
"An idea about information that may become useful later",
"Buy train tickets this Friday",
] {
self.add_item(text)?;
}
Ok(())
}
}
#[derive(Default)]
struct IcalRecord {
summary: String,
note: String,
when_at: Option<String>,
done: bool,
priority: Option<i64>,
categories: Vec<String>,
recurrence: String,
}
fn parse_ical_date(value: &str) -> Option<String> {
if let Ok(dt) = DateTime::parse_from_rfc3339(value) {
return Some(dt.to_rfc3339());
}
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") {
return Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339());
}
if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") {
return Local
.from_local_datetime(&dt)
.single()
.map(|d| d.to_rfc3339());
}
NaiveDate::parse_from_str(value, "%Y%m%d")
.ok()
.and_then(|d| d.and_hms_opt(9, 0, 0))
.and_then(|dt| Local.from_local_datetime(&dt).single())
.map(|d| d.to_rfc3339())
}
fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> Option<String> {
let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?;
let old = DateTime::parse_from_rfc3339(old_when?).ok()?;
let new = DateTime::parse_from_rfc3339(new_when).ok()?;
Some((new + (alarm - old)).to_rfc3339())
}
fn ical_unescape(value: &str) -> String {
value
.replace("\\n", "\n")
.replace("\\N", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
}
fn ical_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace(',', "\\,")
.replace(';', "\\;")
}
fn recurrence_to_rrule(value: &str) -> Option<String> {
let value = value.trim().to_lowercase();
if value.is_empty() {
return None;
}
Some(match value.as_str() {
"daily" => "FREQ=DAILY".into(),
"weekdays" => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into(),
"weekly" => "FREQ=WEEKLY".into(),
"monthly" => "FREQ=MONTHLY".into(),
"yearly" | "annually" => "FREQ=YEARLY".into(),
_ => {
let re = regex::Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap();
let c = re.captures(&value)?;
let freq = match &c[2] {
"day" | "days" => "DAILY",
"week" | "weeks" => "WEEKLY",
"month" | "months" => "MONTHLY",
_ => "YEARLY",
};
format!("FREQ={freq};INTERVAL={}", &c[1])
}
})
}
fn rrule_to_recurrence(value: &str) -> String {
let upper = value.to_uppercase();
let freq = upper
.split(';')
.find_map(|p| p.strip_prefix("FREQ="))
.unwrap_or("");
let interval = upper
.split(';')
.find_map(|p| p.strip_prefix("INTERVAL="))
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(1);
if upper.contains("BYDAY=MO,TU,WE,TH,FR") {
return "weekdays".into();
}
if interval == 1 {
return match freq {
"DAILY" => "daily",
"WEEKLY" => "weekly",
"MONTHLY" => "monthly",
"YEARLY" => "yearly",
_ => "",
}
.into();
}
let unit = match freq {
"DAILY" => "days",
"WEEKLY" => "weeks",
"MONTHLY" => "months",
"YEARLY" => "years",
_ => return String::new(),
};
format!("every {interval} {unit}")
}
fn render_ical(items: &[Item]) -> String {
let mut out = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n",
);
for item in items {
out.push_str("BEGIN:VTODO\r\n");
out.push_str(&format!("UID:rogue-{}@local\r\n", item.id));
out.push_str(&format!(
"DTSTAMP:{}\r\n",
ical_timestamp(&item.created_at)
.unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string())
));
out.push_str(&format!("SUMMARY:{}\r\n", ical_escape(&item.text)));
if !item.note.is_empty() {
out.push_str(&format!("DESCRIPTION:{}\r\n", ical_escape(&item.note)));
}
if let Some(when_at) = item.when_at.as_deref().and_then(ical_timestamp) {
out.push_str(&format!("DUE:{when_at}\r\n"));
}
out.push_str(&format!("PRIORITY:{}\r\n", item.priority));
if !item.categories.is_empty() {
out.push_str(&format!(
"CATEGORIES:{}\r\n",
item.categories
.iter()
.map(|c| ical_escape(&c.name))
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(rrule) = recurrence_to_rrule(&item.recurrence) {
out.push_str(&format!("RRULE:{rrule}\r\n"));
}
if let Some(done) = item.done_at.as_deref() {
out.push_str("STATUS:COMPLETED\r\n");
if let Some(done) = ical_timestamp(done) {
out.push_str(&format!("COMPLETED:{done}\r\n"));
}
} else {
out.push_str("STATUS:NEEDS-ACTION\r\n");
}
out.push_str("END:VTODO\r\n");
}
out.push_str("END:VCALENDAR\r\n");
out
}
fn ical_timestamp(value: &str) -> Option<String> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|d| d.with_timezone(&Utc).format("%Y%m%dT%H%M%SZ").to_string())
}
fn report_groups<'a>(view: &ViewDef, items: &'a [Item]) -> Vec<(String, Vec<&'a Item>)> {
if view.sections.is_empty() {
return vec![(String::new(), items.iter().collect())];
}
view.sections
.iter()
.map(|section| {
(
section.heading.clone(),
items
.iter()
.filter(|item| filter::matches(&section.filter_expr, item).unwrap_or(false))
.collect(),
)
})
.collect()
}
fn report_value(field: &str, item: &Item) -> String {
match field {
"item" => item.text.clone(),
"categories" => item.category_names(),
"when" => item.when_at.clone().unwrap_or_default(),
"priority" => item.priority.to_string(),
"note" => item.note.clone(),
"value" => item
.numeric_value
.map(|v| v.to_string())
.unwrap_or_default(),
"done" => item.done_at.clone().unwrap_or_default(),
"alarm" => item.alarm_at.clone().unwrap_or_default(),
"recurrence" => item.recurrence.clone(),
"created" => item.created_at.clone(),
"updated" => item.updated_at.clone(),
_ => String::new(),
}
}
fn render_markdown(view: &ViewDef, items: &[Item]) -> String {
let mut out = format!("# Rogue Agenda — {}\n\n", view.name);
for (heading, group) in report_groups(view, items) {
if !heading.is_empty() {
out.push_str(&format!("## {}\n\n", markdown(&heading)));
}
out.push('|');
for column in &view.columns {
out.push_str(&format!(" {} |", markdown(&column.heading)));
}
out.push('\n');
out.push('|');
for _ in &view.columns {
out.push_str(" --- |");
}
out.push('\n');
for item in group {
out.push('|');
for column in &view.columns {
out.push_str(&format!(
" {} |",
markdown(&report_value(&column.field, item))
));
}
out.push('\n');
}
out.push('\n');
}
out
}
fn render_html(view: &ViewDef, items: &[Item]) -> String {
let mut out = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Rogue Agenda — {}</title><style>body{{font:16px monospace;background:#c0c0c0;color:#000}}h1,h2{{background:#000080;color:white;padding:.35rem}}table{{border-collapse:collapse;width:100%;margin-bottom:1.5rem}}th{{color:#000080;text-align:left}}th,td{{border-bottom:1px solid #888;padding:.3rem;vertical-align:top}}</style></head><body><h1>Rogue Agenda — {}</h1>",
html(&view.name),
html(&view.name)
);
for (heading, group) in report_groups(view, items) {
if !heading.is_empty() {
out.push_str(&format!("<h2>{}</h2>", html(&heading)));
}
out.push_str("<table><thead><tr>");
for column in &view.columns {
out.push_str(&format!("<th>{}</th>", html(&column.heading)));
}
out.push_str("</tr></thead><tbody>");
for item in group {
out.push_str("<tr>");
for column in &view.columns {
out.push_str(&format!(
"<td>{}</td>",
html(&report_value(&column.field, item)).replace('\n', "<br>")
));
}
out.push_str("</tr>");
}
out.push_str("</tbody></table>");
}
out.push_str("</body></html>\n");
out
}
fn markdown(value: &str) -> String {
value.replace('|', "\\|").replace('\n', "<br>")
}
fn html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn default_columns() -> Vec<ViewColumn> {
[
("item", "Items", 48),
("categories", "Categories", 27),
("when", "When", 20),
("priority", "P", 5),
]
.into_iter()
.map(|(field, heading, width)| ViewColumn {
field: field.into(),
heading: heading.into(),
width,
aggregate: "none".into(),
})
.collect()
}
fn parse_action_spec(spec: &str) -> Result<(String, String)> {
let (kind, value) = spec.trim().split_once(':').unwrap_or((spec.trim(), ""));
let kind = kind.trim().to_lowercase();
if !matches!(
kind.as_str(),
"assign" | "exclude" | "priority" | "value" | "when" | "alarm" | "repeat" | "done"
) {
bail!("rule action must be assign, exclude, priority, value, when, alarm, repeat, or done")
}
if matches!(
kind.as_str(),
"priority" | "value" | "when" | "alarm" | "repeat" | "done"
) && value.trim().is_empty()
{
bail!("rule action {kind} needs a value")
}
if kind == "repeat" {
next_occurrence("2026-01-05T09:00:00+00:00", value.trim())?;
}
Ok((kind, value.trim().into()))
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "yes" | "true" | "on" | "done"
)
}
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());
}
let mut columns = vec![];
for raw in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let parts: Vec<&str> = raw.split(':').collect();
let field = parts[0].trim().to_lowercase();
if !matches!(
field.as_str(),
"item"
| "categories"
| "when"
| "priority"
| "note"
| "value"
| "done"
| "alarm"
| "recurrence"
| "created"
| "updated"
) {
bail!("unknown view column {field}");
}
let width = parts
.get(1)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(20)
.clamp(4, 100);
let heading = parts
.get(2)
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| default_heading(&field).into());
let aggregate = parts
.get(3)
.map(|s| s.trim().to_lowercase())
.unwrap_or_else(|| "none".into());
if !matches!(
aggregate.as_str(),
"none" | "sum" | "avg" | "count" | "min" | "max"
) {
bail!("unsupported aggregate {aggregate}")
}
columns.push(ViewColumn {
field,
heading,
width,
aggregate,
});
}
if columns.is_empty() {
bail!("a view needs at least one column")
}
Ok(columns)
}
fn parse_sections_spec(spec: &str) -> Result<Vec<ViewSection>> {
let mut sections = vec![];
for raw in spec.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let mut parts = raw.split('|');
let heading = parts.next().unwrap_or_default().trim();
let expr = parts.next().unwrap_or_default().trim();
let collapsed = parts
.next()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("collapsed"));
if heading.is_empty() {
bail!("section heading cannot be empty")
}
filter::parse(expr).with_context(|| format!("invalid filter for section {heading}"))?;
sections.push(ViewSection {
id: 0,
heading: heading.into(),
filter_expr: expr.into(),
collapsed,
});
}
Ok(sections)
}
fn default_heading(field: &str) -> &str {
match field {
"item" => "Items",
"categories" => "Categories",
"when" => "When",
"priority" => "P",
"note" => "Note",
"value" => "Value",
"done" => "Done",
"alarm" => "Alarm",
"recurrence" => "Repeats",
"created" => "Created",
"updated" => "Updated",
_ => field,
}
}
fn json(s: &str) -> String {
format!(
"\"{}\"",
s.replace('\\', "\\\\")
.replace('\"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
)
}
fn opt_json(s: Option<&str>) -> String {
s.map(json).unwrap_or_else(|| "null".into())
}
fn csv(s: &str) -> String {
format!("\"{}\"", s.replace('\"', "\"\""))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn persists_items_and_auto_assigns() {
let d = tempdir().unwrap();
let p = d.path().join("test.agnd");
let mut db = Database::open(&p).unwrap();
db.add_item("Call Ada tomorrow").unwrap();
let view = db
.views()
.unwrap()
.into_iter()
.find(|v| v.name == "All Items")
.unwrap();
let items = db.items(&view, "").unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].when_at.is_some());
assert!(items[0].categories.iter().any(|c| c.name == "Calls"));
}
#[test]
fn category_assignment_toggles() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("x.agnd")).unwrap();
let item = db.add_item("Unclassified thought").unwrap();
let cat = db
.categories()
.unwrap()
.into_iter()
.find(|c| c.name == "Projects")
.unwrap();
db.toggle_assignment(item, cat.id).unwrap();
assert!(db.item_category_ids(item).unwrap().contains(&cat.id));
db.toggle_assignment(item, cat.id).unwrap();
assert!(!db.item_category_ids(item).unwrap().contains(&cat.id));
}
#[test]
fn category_views_inherit_through_the_hierarchy() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("inherit.agnd")).unwrap();
let parent = db
.save_category(None, "Research", None, "standard", "", false)
.unwrap();
let child = db
.save_category(None, "Rust", Some(parent), "standard", "", false)
.unwrap();
let item = db.add_item("Borrow ownership notes").unwrap();
db.toggle_assignment(item, child).unwrap();
let view_id = db
.save_view_design(
None,
"Research view",
"category",
"Research",
"manual",
true,
"",
"item:48,categories:27,when:20,priority:5",
"",
)
.unwrap();
let view = db
.views()
.unwrap()
.into_iter()
.find(|v| v.id == view_id)
.unwrap();
assert_eq!(db.items(&view, "").unwrap().len(), 1);
}
#[test]
fn dependency_cycles_are_rejected() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("deps.agnd")).unwrap();
let first = db.add_item("First").unwrap();
let second = db.add_item("Second").unwrap();
db.toggle_dependency(second, first).unwrap();
assert!(db.toggle_dependency(first, second).is_err());
assert_eq!(db.unmet_dependencies(second).unwrap(), 1);
}
#[test]
fn view_design_roundtrips_columns_sections_and_boolean_filter() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("views.agnd")).unwrap();
let urgent = db.add_item("Urgent proposal").unwrap();
db.update_item(
urgent,
&ItemChanges {
priority: Some(1),
numeric_value: Some(Some(125.0)),
..Default::default()
},
)
.unwrap();
db.add_item("Someday proposal").unwrap();
let id = db
.save_view_design(
None,
"Focused",
"list",
"",
"priority",
true,
"priority<=2 and open",
"item:60:Task:none,value:20:Cost:sum",
"Urgent|priority=1;Later|priority>1|collapsed",
)
.unwrap();
let view = db
.views()
.unwrap()
.into_iter()
.find(|v| v.id == id)
.unwrap();
assert_eq!(view.columns[1].heading, "Cost");
assert_eq!(view.columns[1].aggregate, "sum");
assert_eq!(view.sections.len(), 2);
assert!(view.sections[1].collapsed);
assert_eq!(db.items(&view, "").unwrap().len(), 1);
}
#[test]
fn category_rules_apply_and_invalid_edits_are_atomic() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("rules.agnd")).unwrap();
let important = db
.save_category(None, "Important", None, "standard", "", false)
.unwrap();
db.save_primary_rule(important, "text~urgent", "assign:")
.unwrap();
let item = db.add_item("Urgent customer request").unwrap();
assert!(db.item_category_ids(item).unwrap().contains(&important));
let promoter = db
.save_category(None, "Promoter", None, "standard", "", false)
.unwrap();
db.save_primary_rule(promoter, "category=Important", "priority:1")
.unwrap();
db.apply_rules_to_all().unwrap();
let all = db
.views()
.unwrap()
.into_iter()
.find(|v| v.name == "All Items")
.unwrap();
assert_eq!(db.items(&all, "").unwrap()[0].priority, 1);
assert!(
db.save_primary_rule(important, "priority nope 2", "assign:")
.is_err()
);
assert_eq!(
db.category_rules(important).unwrap()[0].condition_expr,
"text~urgent"
);
}
#[test]
fn completing_a_recurring_item_creates_the_next_occurrence() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("recurring.agnd")).unwrap();
let id = db.add_item("Weekly project review").unwrap();
db.update_item(
id,
&ItemChanges {
note: Some("Bring the status sheet".into()),
priority: Some(2),
when_at: Some(Some("2026-09-01T14:30:00+00:00".into())),
alarm_at: Some(Some("2026-09-01T13:30:00+00:00".into())),
numeric_value: Some(Some(45.0)),
recurrence: Some("weekly".into()),
..Default::default()
},
)
.unwrap();
let projects = db
.categories()
.unwrap()
.into_iter()
.find(|c| c.name == "Projects")
.unwrap();
db.toggle_assignment(id, projects.id).unwrap();
db.toggle_done(&[id]).unwrap();
let all = db
.views()
.unwrap()
.into_iter()
.find(|v| v.name == "All Items")
.unwrap();
let items = db.items(&all, "").unwrap();
assert_eq!(items.len(), 2);
let completed = items.iter().find(|item| item.id == id).unwrap();
let next = items.iter().find(|item| item.id != id).unwrap();
assert!(completed.done_at.is_some());
assert!(next.done_at.is_none());
assert_eq!(next.note, "Bring the status sheet");
assert_eq!(next.priority, 2);
assert_eq!(next.numeric_value, Some(45.0));
assert_eq!(next.recurrence, "weekly");
assert!(
next.when_at
.as_deref()
.unwrap()
.contains("2026-09-08T14:30:00")
);
assert!(
next.alarm_at
.as_deref()
.unwrap()
.contains("2026-09-08T13:30:00")
);
assert!(next.categories.iter().any(|c| c.name == "Projects"));
db.toggle_done(&[id]).unwrap();
assert_eq!(db.items(&all, "").unwrap().len(), 2);
}
#[test]
fn exports_view_scoped_markdown_and_html_reports() {
let d = tempdir().unwrap();
let mut db = Database::open(&d.path().join("report.agnd")).unwrap();
let keep = db.add_item("Keep <this> report").unwrap();
db.update_item(
keep,
&ItemChanges {
priority: Some(1),
..Default::default()
},
)
.unwrap();
db.add_item("Omit report").unwrap();
db.save_view_design(
None,
"Priority report",
"list",
"",
"priority",
true,
"priority=1",
"item:70:Action:none,priority:30:Rank:none",
"Important|priority=1",
)
.unwrap();
let md = d.path().join("report.md");
db.export("markdown", &md, Some("Priority report")).unwrap();
let md = fs::read_to_string(md).unwrap();
assert!(md.contains("## Important"));
assert!(md.contains("Keep <this> report"));
assert!(!md.contains("Omit report"));
let html_path = d.path().join("report.html");
db.export("html", &html_path, Some("Priority report"))
.unwrap();
let html = fs::read_to_string(html_path).unwrap();
assert!(html.contains("Keep &lt;this&gt; report"));
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();
let mut source = Database::open(&d.path().join("source.agnd")).unwrap();
let id = source.add_item("Prepare launch").unwrap();
source
.update_item(
id,
&ItemChanges {
note: Some("Bring charts".into()),
priority: Some(1),
when_at: Some(Some("2026-09-01T14:30:00+00:00".into())),
..Default::default()
},
)
.unwrap();
let project = source
.categories()
.unwrap()
.into_iter()
.find(|c| c.name == "Projects")
.unwrap();
source.toggle_assignment(id, project.id).unwrap();
source.toggle_done(&[id]).unwrap();
source
.update_item(
id,
&ItemChanges {
recurrence: Some("every 2 weeks".into()),
..Default::default()
},
)
.unwrap();
let path = d.path().join("tasks.ics");
source.export("ics", &path, None).unwrap();
let mut target = Database::open(&d.path().join("target.agnd")).unwrap();
assert_eq!(target.import_path(&path).unwrap(), 1);
let all = target
.views()
.unwrap()
.into_iter()
.find(|v| v.name == "All Items")
.unwrap();
let items = target.items(&all, "").unwrap();
assert_eq!(items[0].text, "Prepare launch");
assert_eq!(items[0].note, "Bring charts");
assert_eq!(items[0].priority, 1);
assert!(
items[0]
.when_at
.as_deref()
.unwrap()
.contains("2026-09-01T14:30:00")
);
assert!(items[0].done_at.is_some());
assert_eq!(items[0].recurrence, "every 2 weeks");
assert!(items[0].categories.iter().any(|c| c.name == "Projects"));
}
}