2176 lines
83 KiB
Rust
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(§ion.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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
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 <this> 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"));
|
|
}
|
|
}
|