complete category-scoped values, rules, and report parity

- Add category-bound date and numeric values
- Complete rule execution and conflict handling
- Expand Markdown and HTML report output
This commit is contained in:
Chili Palmer
2026-08-20 21:01:02 +02:00
parent cb0e480b79
commit 16150b5a8f
19 changed files with 2519 additions and 419 deletions

View File

@@ -98,6 +98,114 @@ pub(super) fn migrate(connection: &Connection) -> Result<()> {
if version < 4 {
connection.pragma_update(None, "user_version", 4)?;
}
if version < 5 {
add_lotus_values_and_rules(connection)?;
connection.pragma_update(None, "user_version", 5)?;
}
Ok(())
}
fn add_lotus_values_and_rules(connection: &Connection) -> Result<()> {
connection.execute_batch(
"BEGIN;
ALTER TABLE item_categories RENAME TO old_item_categories;
CREATE TABLE 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 CHECK(assignment IN ('explicit','automatic','conditional','excluded')),
PRIMARY KEY(item_id, category_id, assignment)
);
INSERT INTO item_categories(item_id,category_id,assignment)
SELECT item_id,category_id,assignment FROM old_item_categories;
DROP TABLE old_item_categories;
CREATE INDEX idx_item_categories_category ON item_categories(category_id);
CREATE TABLE item_values (
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
numeric_value REAL,
date_value TEXT,
assignment TEXT NOT NULL DEFAULT 'explicit'
CHECK(assignment IN ('explicit','automatic','conditional')),
PRIMARY KEY(item_id, category_id),
CHECK((numeric_value IS NULL) OR (date_value IS NULL))
);
CREATE TABLE rule_action_runs (
rule_id INTEGER NOT NULL REFERENCES category_rules(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY(rule_id,item_id)
);
CREATE TABLE rule_conflicts (
item_id INTEGER PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
message TEXT NOT NULL,
updated_at TEXT NOT NULL
);
COMMIT;",
)?;
for (column, definition) in [
(
"category_id",
"INTEGER REFERENCES categories(id) ON DELETE SET NULL",
),
("number_label", "TEXT NOT NULL DEFAULT ''"),
("decimals", "INTEGER NOT NULL DEFAULT 2"),
("decimal_separator", "TEXT NOT NULL DEFAULT '.'"),
("thousands_separator", "TEXT NOT NULL DEFAULT 'comma'"),
("negative_style", "TEXT NOT NULL DEFAULT 'minus'"),
("percent_total", "INTEGER NOT NULL DEFAULT 0"),
("alignment", "TEXT NOT NULL DEFAULT 'left'"),
] {
if !column_exists(connection, "view_columns", column)? {
connection.execute_batch(&format!(
"ALTER TABLE view_columns ADD COLUMN {column} {definition};"
))?;
}
}
connection.execute_batch(
"UPDATE view_columns
SET category_id=(SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1),
field='numeric'
WHERE field='value';
INSERT INTO categories(name,kind,sort_order)
SELECT 'Value','numeric',(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories)
WHERE EXISTS(SELECT 1 FROM items WHERE numeric_value IS NOT NULL)
AND NOT EXISTS(SELECT 1 FROM categories WHERE kind='numeric')
AND NOT EXISTS(SELECT 1 FROM categories WHERE name='Value' COLLATE NOCASE);
INSERT INTO categories(name,kind,sort_order)
SELECT 'Numeric Value','numeric',(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories)
WHERE EXISTS(SELECT 1 FROM items WHERE numeric_value IS NOT NULL)
AND NOT EXISTS(SELECT 1 FROM categories WHERE kind='numeric');
UPDATE view_columns
SET category_id=(SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1)
WHERE field='numeric' AND category_id IS NULL;
INSERT OR IGNORE INTO item_values(item_id,category_id,numeric_value,assignment)
SELECT i.id,
COALESCE(
(SELECT ic.category_id FROM item_categories ic JOIN categories c ON c.id=ic.category_id
WHERE ic.item_id=i.id AND ic.assignment<>'excluded' AND c.kind='numeric' LIMIT 1),
(SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1)
),
i.numeric_value,'explicit'
FROM items i WHERE i.numeric_value IS NOT NULL
AND EXISTS(SELECT 1 FROM categories WHERE kind='numeric');
INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment)
SELECT item_id,category_id,'explicit' FROM item_values;
UPDATE items SET numeric_value=NULL
WHERE numeric_value IS NOT NULL AND EXISTS(
SELECT 1 FROM item_values WHERE item_values.item_id=items.id
);
UPDATE category_rules SET action_kind='none'
WHERE action_kind='assign' AND TRIM(action_value)='';
INSERT OR IGNORE INTO rule_action_runs(rule_id,item_id)
SELECT r.id,positive.item_id FROM category_rules r
JOIN item_categories positive ON positive.category_id=r.category_id
AND positive.assignment<>'excluded'
WHERE r.action_kind<>'none' AND NOT EXISTS(
SELECT 1 FROM item_categories excluded
WHERE excluded.item_id=positive.item_id
AND excluded.category_id=positive.category_id
AND excluded.assignment='excluded'
);",
)?;
Ok(())
}
@@ -124,3 +232,63 @@ fn column_exists(connection: &Connection, table: &str, column: &str) -> Result<b
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(names.iter().any(|name| name == column))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_four_numbers_migrate_without_losing_values_or_provenance() {
let connection = Connection::open_in_memory().unwrap();
connection
.execute_batch(
"PRAGMA foreign_keys=ON;
CREATE TABLE items(id INTEGER PRIMARY KEY,text TEXT NOT NULL,note TEXT NOT NULL DEFAULT '',priority INTEGER NOT NULL DEFAULT 3,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,recurrence TEXT NOT NULL DEFAULT '');
CREATE TABLE categories(id INTEGER PRIMARY KEY,name TEXT NOT NULL UNIQUE,parent_id INTEGER,kind TEXT NOT NULL DEFAULT 'standard',match_text TEXT NOT NULL DEFAULT '',exclusive INTEGER NOT NULL DEFAULT 0,sort_order INTEGER NOT NULL DEFAULT 0);
CREATE TABLE item_categories(item_id INTEGER NOT NULL REFERENCES items(id),category_id INTEGER NOT NULL REFERENCES categories(id),assignment TEXT NOT NULL DEFAULT 'explicit',PRIMARY KEY(item_id,category_id));
CREATE TABLE views(id INTEGER PRIMARY KEY,name TEXT NOT NULL UNIQUE,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,filter_expr TEXT NOT NULL DEFAULT '');
CREATE TABLE view_columns(id INTEGER PRIMARY KEY,view_id INTEGER NOT NULL REFERENCES views(id),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);
INSERT INTO categories(id,name,kind) VALUES(1,'Hours','numeric');
INSERT INTO items(id,text,numeric_value,created_at,updated_at) VALUES(1,'Legacy',42.25,'now','now');
INSERT INTO item_categories VALUES(1,1,'explicit');
INSERT INTO views(id,name) VALUES(1,'Legacy');
INSERT INTO view_columns(view_id,field,heading) VALUES(1,'value','Hours');
PRAGMA user_version=4;",
)
.unwrap();
migrate(&connection).unwrap();
assert_eq!(
connection
.query_row(
"SELECT numeric_value FROM item_values WHERE item_id=1 AND category_id=1",
[],
|row| row.get::<_, f64>(0),
)
.unwrap(),
42.25
);
assert!(
connection
.query_row("SELECT numeric_value FROM items WHERE id=1", [], |row| {
row.get::<_, Option<f64>>(0)
})
.unwrap()
.is_none()
);
connection
.execute("INSERT INTO item_categories VALUES(1,1,'conditional')", [])
.unwrap();
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM item_categories WHERE item_id=1 AND category_id=1",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
2
);
}
}