diff --git a/PRESETS.md b/PRESETS.md index 9450a8d..b0ba02a 100644 --- a/PRESETS.md +++ b/PRESETS.md @@ -54,7 +54,8 @@ It provides: and Customer. - Calls, meetings, and follow-ups under an Activities hierarchy. - Open, waiting, and resolved issue states. -- Numeric expense records with a summed Amount column. +- Category-scoped expense values with a summed Amount column and per-section + `% of total` companion. - A sectioned status report divided into urgent and routine material. - Filing rules that collect priority-one work into the status report and detect follow-up wording. @@ -68,7 +69,8 @@ CRM, client service, fundraising, vendor management, or case tracking. Study Deck is inspired by the idea of using Agenda items and notes as a learning system. The item text is a question, its multiline note is the answer, `When` is -the next review date, and the numeric value is a visible learning-cycle number. +the next review date, and the Cycle numeric category holds its learning-cycle +number. It provides: @@ -119,7 +121,7 @@ It provides: - Course categories for Breakfast, Main Dishes, Desserts, and Drinks. - Cuisine, dietary, ingredient, Quick, and Favorites classifications. -- A numeric Servings value shown alongside each recipe. +- An independent Servings numeric-category value shown alongside each recipe. - Recipe Box, Favorites, Main Dishes, Quick Meals, Vegetarian, and sectioned By Course views. - Five small original recipes covering different courses and dietary tags. @@ -135,7 +137,8 @@ ingredients as children of Ingredients to turn the preset into a deeper index. ## `rides` Bicycle Log demonstrates numeric reporting and several overlapping category -dimensions. Ride distance is stored as the item's numeric value in kilometres. +dimensions. Ride distance is stored under the Distance numeric category in +kilometres. It provides: @@ -149,8 +152,9 @@ It provides: - Rules that recognize ride and maintenance wording. - `logride` and `servicereminder` capture macros. -Use `F6` on a newly captured ride to record its numeric distance and date, then -assign bicycle, ride type, route, and weather with `F3`. +Assign Distance with `F3`, select the Distance cell with Left/Right, and press +`F2` to enter kilometres. Use `F6` for the ride date, then assign bicycle, ride +type, route, and weather with `F3`. ## `people` @@ -166,7 +170,7 @@ It provides: hiring activity. - People Directory, Upcoming Reviews, Goals, Development, Candidates, and Achievements views. -- A numeric Progress example on a dated goal. +- A category-scoped Progress value displayed on dated goals. - Filing rules for `Person:`, `Review:`, and `Goal:` capture conventions. - `addperson` and `addgoal` prompt-driven macros. diff --git a/PROJECT.md b/PROJECT.md index 0adf320..0a85f8c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -70,7 +70,8 @@ Research URLs: - [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL, transactions, integrity-friendly soft deletion, and automatic checkpoints. -- [x] Items with free text, note, priority, When, Done, alarm, numeric value, +- [x] Items with free text, note, priority, When, Done, alarm, independent + category-scoped numeric/date values, recurring schedule, creation/update timestamps, manual order, and prerequisite links. Completion advances recurring work and carries alarms and filing forward. - [x] Categories with hierarchy, inheritance in queries, kinds (standard, date, @@ -136,28 +137,21 @@ Research URLs: ## Supported with a modern interpretation -- [x] Numeric calculations currently use one optional `numeric_value` per item and - expose five named aggregate modes: `sum`, `avg`, `count`, `min`, and `max` - (`model/enums.rs` `Aggregate`, `ui.rs` `aggregate_value`). Sum, average, minimum, - and maximum operate on the populated values in a section. Count currently counts - every item in the section, whereas Lotus Count counted populated entries in the - numeric column. The names therefore align with Lotus, but the data model, - category scoping, Count semantics, formatting, and presentation do not yet have - full parity. -- [x] Conditions / actions: each `CategoryRule` pairs a parsed condition - expression (`filter.rs` `Predicate`: text/note containment, category membership, - priority and numeric-value comparison, done / has-note / has-date / due-within / - overdue / alarm / recurring) with a deterministic action (`RuleActionKind`: - assign, exclude, priority, value, when, alarm, repeat, done). This is a useful - modern predicate-to-mutation engine, but it is not yet a faithful implementation - of Lotus conditions and actions. Re-evaluation on item change is currently tied - to automatic filing, and a conditional assignment is not withdrawn when its - predicate becomes false. `Done` overlaps one Lotus Special action; rule-driven - export and discard are missing. +- [x] Numeric calculations use independent item–numeric-category values and + category-bound view columns. Count, Total, Average, Minimum, and Maximum follow + populated-cell semantics per section; formatting and `% of total` are column + properties. The retired `items.numeric_value` column remains only as a migration + bridge for version-4 documents and older API callers. +- [x] Conditions and actions follow separate Lotus lifecycles. Conditions maintain + reversible conditional assignments to their own categories; actions fire once + on source-category entry and make explicit persistent changes. Category-scoped + date/numeric ranges and actions, removal, Done/export/discard Special actions, + scoped Utilities Execute, and inspectable non-convergence state are supported. - [x] Printing: view-aware Markdown and styled HTML reports are the portable print/preview foundation, alongside CSV and JSON data exports. Markdown and HTML - currently omit aggregate rows, percentage-of-total columns, numeric formatting, - and other report-layout fidelity listed below. + include live-view aggregate rows, percentage-of-total columns, portable numeric + formatting, relative widths/alignment, print CSS, metadata, and optional headers + and footers. - [x] Backups: SQLite durability is supplemented by explicit/manual and backup-on-open `.agnd.bak` snapshots after a full WAL checkpoint. Ordinary filesystem backup tools also work on `.agnd` files. @@ -174,60 +168,60 @@ file-format compatibility are not parity goals. ### Numeric data and calculations -- [ ] Store a separate value for every item–numeric-category assignment instead of - the current single `items.numeric_value` scalar. Preserve explicit, automatic, - and conditional assignment provenance while doing so. -- [ ] Bind numeric view columns to a specific numeric category so several values, +- [x] Store a separate value for every item–numeric-category assignment rather + than relying on the legacy `items.numeric_value` scalar. Preserve explicit, + automatic, and conditional assignment provenance while doing so. +- [x] Bind numeric view columns to a specific numeric category so several values, such as Hours, Rate, Cost, and Budget, can coexist on one item and be displayed, filtered, sorted, edited, and aggregated independently. -- [ ] Scope numeric conditions and numeric actions to a chosen numeric category, +- [x] Scope numeric conditions and numeric actions to a chosen numeric category, with inside/outside minimum and maximum ranges and assignment removal. -- [ ] Match Lotus aggregate semantics per numeric column and per section: Count +- [x] Match Lotus aggregate semantics per numeric column and per section: Count populated numeric entries rather than rows, and calculate Total, Average, Minimum, and Maximum from that column's values only. -- [ ] Add per-numeric-column display properties: label or currency symbol, decimal +- [x] Add per-numeric-column display properties: label or currency symbol, decimal places, thousands separator, negative-number style, and full-precision storage independent of rounded display. -- [ ] Implement `Display % of total` as a derived, non-editable companion column +- [x] Implement `Display % of total` as a derived, non-editable companion column calculated within each section. -- [ ] Make macro highlight context cell-aware. `#HIGHLIGHT_VALUE` must expose the +- [x] Make macro highlight context cell-aware. `#HIGHLIGHT_VALUE` must expose the highlighted numeric, date, text, category, or item value so Agenda-style macro arithmetic can operate on document data rather than only on item text. ### Conditions and actions -- [ ] Separate Lotus-style conditions from actions. A condition creates a live +- [x] Separate Lotus-style conditions from actions. A condition creates a live conditional assignment to its own category; an action creates an explicit, persistent assignment or value change after an item enters its source category. -- [ ] Withdraw conditional assignments automatically when their Text, Assignment, +- [x] Withdraw conditional assignments automatically when their Text, Assignment, Date, or Numeric condition becomes false, while preserving explicit assignments. -- [ ] Re-evaluate affected rules whenever their inputs change, independently of the +- [x] Re-evaluate affected rules whenever their inputs change, independently of the automatic text-filing preference. -- [ ] Implement category-scoped Assignment, Date, and Numeric conditions with the +- [x] Implement category-scoped Assignment, Date, and Numeric conditions with the original positive/negative and inside/outside-range behavior. -- [ ] Implement Assignment, Date, and Numeric actions with explicit persistence, +- [x] Implement Assignment, Date, and Numeric actions with explicit persistence, including assignment removal and category-specific date and numeric values. -- [ ] Complete Special actions: designate as done, export to a selected structured - destination, and discard. `Done` exists today; export and discard do not. -- [ ] Add a user-facing re-evaluation command equivalent to Utilities `Execute`, +- [x] Complete Special actions: designate as done, export to a selected structured + destination, and discard. +- [x] Add a user-facing re-evaluation command equivalent to Utilities `Execute`, scoped to the current item, marked items, section, view, or whole document. This means applying rules to existing data, not executing an external process. -- [ ] Surface circular or conflicting rules as inspectable document state instead +- [x] Surface circular or conflicting rules as inspectable document state instead of only returning a non-convergence error. ### Markdown, HTML, and modern printing -- [ ] Emit calculation-label footer rows and results for every configured aggregate +- [x] Emit calculation-label footer rows and results for every configured aggregate in every Markdown and HTML section, using the same values as the live view. -- [ ] Emit `% of total` companion columns when enabled and keep percentage values +- [x] Emit `% of total` companion columns when enabled and keep percentage values consistent between the TUI, Markdown, and HTML. -- [ ] Apply each numeric column's label/currency, decimal, separator, and negative +- [x] Apply each numeric column's label/currency, decimal, separator, and negative formatting consistently in Markdown and HTML reports. -- [ ] Preserve configured column alignment and relative widths in HTML and use +- [x] Preserve configured column alignment and relative widths in HTML and use appropriate Markdown alignment markers where Markdown can represent them. -- [ ] Add print-focused HTML styling: repeated table headers, sensible page breaks, +- [x] Add print-focused HTML styling: repeated table headers, sensible page breaks, section continuity, note wrapping, and optional document/view headers and footers. -- [ ] Include report metadata needed to understand an export: document description, +- [x] Include report metadata needed to understand an export: document description, view name, active filters, and generation time, without changing data-only CSV and JSON exports into presentation formats. @@ -244,11 +238,6 @@ file-format compatibility are not parity goals. ## Near-term plan -1. Implement per-numeric-category values and category-bound numeric columns. -2. Correct conditional-assignment lifecycle and separate conditions from explicit - actions. -3. Complete aggregate, percentage, numeric-formatting, Markdown, and HTML report - parity. -4. Add desktop alarm integration and in-application snooze controls. -5. Add encrypted documents and secure unlock handling. -6. Add property-based tests for complex hierarchy, rule, and numeric combinations. +1. Add desktop alarm integration and in-application snooze controls. +2. Add encrypted documents and secure unlock handling. +3. Add property-based tests for complex hierarchy, rule, and numeric combinations. diff --git a/README.md b/README.md index e16a696..5f9082a 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ database with the `.agnd` extension. ## Features - Free-form items with multiline notes, priority, due dates, done dates, alarms, - numeric values, recurring schedules, and prerequisites. Completing a recurring - item creates its next dated occurrence and carries its alarm and filing forward. + independent date and numeric category values, recurring schedules, and + prerequisites. Completing a recurring item creates its next dated occurrence + and carries its alarm and filing forward. - Hierarchical categories with inherited membership and explicit many-to-many assignment and negative exclusions: an item exists once but can appear in many contexts. @@ -23,14 +24,15 @@ database with the `.agnd` extension. `today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates. - Live views for all items, tasks, upcoming work, completed work, and a datebook. - Full-text search plus composable Boolean filters over text, notes, categories, - dates, done state, priority, numeric value, alarms, recurrence, and relative due - windows. + dates, done state, priority, category-scoped numeric values, alarms, recurrence, + and relative due windows. - Agenda-style View Manager and Category Manager screens with editable live view definitions, hierarchy, category kinds, match phrases, and exclusivity. - Persistent per-view columns, custom headings and widths, numeric aggregates, filtered multi-section layouts, collapsed sections, and view reordering. -- User-authored category rules with a live match preview and deterministic actions - for assignments, exclusions, priority, value, dates, and completion. +- Lotus-style live conditions and persistent actions with match previews, + reversible conditional assignments, category-scoped date/numeric ranges and + values, assignment removal, completion, structured export, and discard. - Marking and bulk completion, soft deletion with Trash recovery, sorting, and dependency visibility. - View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar @@ -130,13 +132,14 @@ or build failure stops the gate. | Key | Action | | --- | --- | -| Arrows or `h j k l` | Move selection / scroll | +| Up/Down or `j`/`k` | Move between item rows | +| Left/Right | Move between cells/columns | | `Insert` or `n` | Add a free-form item | -| `F2` or `e` | Edit the selected item | +| `F2` or `e` | Edit the selected item or bound value cell | | `F3` or `c` | Assign categories | | `F4` or `d` | Toggle done | | `F5` | Edit the item's note | -| `F6` or `p` | Edit item properties | +| `F6` or `p` | Edit priority, When, alarm, and recurrence | | `F7` or `Space` | Mark for bulk operations | | `F8` or `v` | View Manager | | `F9` | Category Manager | @@ -167,7 +170,8 @@ as typed TOML at `~/.config/rogue-agenda/preferences.toml`. The screen controls: - carriage-return and item markers - autosave checkpoint interval and destructive-action confirmations - ISO, US, European, or long date display and 12/24-hour time -- decimal and thousands separators +- decimal and thousands separators for legacy unbound numeric displays; bound + numeric columns carry their own portable report formatting Greenscreen, Nord, and Catppuccin Mocha use dedicated selected-row foreground/background pairs with at least 7:1 relative-luminance contrast. @@ -184,7 +188,8 @@ retention (`on-demand`, `on-close`, `end-of-day`, or `immediate`), completed-ite policy, automatic filing, numeric date order, first day of the week, default and named times, and note-editor tab width. `this week` and `next week` honor the chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured -times. +times, plus whether Markdown/HTML reports include document/view headers and +footers. Enumerated preferences and document settings use choice popovers, so values such as themes, yes/no switches, policies, date formats, and week boundaries cannot be @@ -258,7 +263,9 @@ Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one, `Ctrl-Up`/`Ctrl-Down` to reorder it, or `Delete` to remove it. Boolean filters use `and`, `or`, `not`, and parentheses. Available predicates -include `text~rust`, `note~budget`, `category=Work`, `priority<=2`, `value>100`, +include `text~rust`, `note~budget`, `category=Work`, `priority<=2`, +`numeric:Hours>7`, `numeric:Cost inside 100..500`, +`date:"Review Date" outside 2026-08-01..2026-08-31`, `done`, `open`, `dated`, `undated`, `due<=7d`, `overdue`, `has-note`, `alarm`, `recurring`, and `nonrecurring`. For example: @@ -267,15 +274,32 @@ For example: category=Work and open and (priority<=2 or due<=3d) ``` -Columns use `field:width:heading:aggregate`, separated by commas. Fields are -`item`, `categories`, `when`, `priority`, `note`, `value`, `done`, `alarm`, -`recurrence`, `created`, and `updated`; aggregates are `none`, `sum`, `avg`, -`count`, `min`, or `max`. +Columns use `field:width:heading:aggregate`, separated by commas. Ordinary fields +are `item`, `categories`, `when`, `priority`, `note`, `done`, `alarm`, +`recurrence`, `created`, and `updated`. Category values use `numeric[Category]` +or `date[Category]`; aggregates are `none`, `sum`, `avg`, `count`, `min`, or +`max`. A view may sort by a bound field such as `numeric[Hours]`. ```text -item:55:Action:none,when:25:Due:none,value:20:Cost:sum +item:55:Action:none,when:25:Due:none,numeric[Cost]:20:Cost:sum ``` +Numeric columns accept optional formatting fields after the aggregate: +`label:decimals:decimal-separator:thousands:negative:percent:alignment`. +Decimal separator is `dot` or `comma`; thousands is `comma`, `dot`, `space`, or +`none`; negative style is `minus`, +`parentheses`, or `trailing`; use `percent` to add a non-editable `% of total` +companion column. + +```text +numeric[Cost]:20:Cost:sum:$:2:dot:comma:parentheses:percent:right +``` + +Count counts populated numeric cells, not section rows. Total, Average, Minimum, +and Maximum use only the bound category's populated values. Left/Right selects a +cell; `F2` edits a bound numeric or date value. `#HIGHLIGHT_VALUE` and +`#HIGHLIGHT_TYPE` follow that cell for macro arithmetic. + Sections use `heading|filter`, separated by semicolons. Add `|collapsed` to make a section initially collapsed. @@ -286,14 +310,22 @@ Urgent|priority=1;Upcoming|due<=7d;Someday|undated|collapsed ## Automatic category rules Open the Category Manager with `F9`, then create or edit a category. In addition -to simple match phrases, each category can have a Boolean rule condition and an -action. The form previews how many existing items match before saving. +to simple match phrases, each category can have a Boolean condition and an +action. The form previews how many existing items match before saving. A true +condition creates a live conditional assignment to the category being edited; +that assignment is withdrawn when the condition becomes false, without removing +an explicit assignment. This lifecycle is independent of automatic text filing. -Actions use `action:value`: `assign:Category`, `exclude:Category`, `priority:1`, -`value:125.50`, `when:tomorrow at 3pm`, `alarm:tomorrow at 2pm`, -`repeat:weekdays`, or `done:true`. `assign:` with no value assigns the category -being edited. Rules are reapplied to existing information and whenever an item -changes. +An action fires once when an item enters the category and creates a persistent +change. Actions use `action:value`: `assign:Category`, `exclude:Category`, +`remove:Category`, `priority:1`, `numeric:Hours=7.5`, +`date:Review Date=tomorrow`, `when:tomorrow at 3pm`, +`alarm:tomorrow at 2pm`, `repeat:weekdays`, `done:true`, +`export:jsonl|archive.jsonl`, or `discard:true`. + +Use `F10`, then `u`, for Utilities Execute. It reapplies conditions and actions +to the current item, marked items, current section, current view, or whole +document. `F10`, then `f`, shows circular/non-converging rule conflicts. ## Recurring work @@ -301,7 +333,8 @@ Open item properties with `F6` and set a recurrence to `daily`, `weekdays`, `weekly`, `monthly`, `yearly`, or a rule such as `every 2 weeks`. The item must have a When date. Marking it done preserves the completed occurrence and creates the next open occurrence. Rogue Agenda shifts its alarm by the same interval and -copies its note, priority, numeric value, recurrence, and explicit category +copies its note, priority, recurrence, explicit category assignments, and +category-scoped date/numeric values assignments. iCalendar import and export maps these schedules to `RRULE`. ## Design notes diff --git a/src/app.rs b/src/app.rs index 15ee4d0..204d16f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,13 +11,14 @@ use ratatui::layout::Rect; use crate::{ db::Database, + filter, macro_lang::{ MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name, parse_key_binding, }, model::{ - Category, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, - TrashPolicy, ViewDef, ViewKind, WeekStart, + Category, CategoryKind, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges, + MacroDef, TrashPolicy, ViewDef, ViewKind, WeekStart, }, preferences::AppPreferences, }; @@ -41,6 +42,11 @@ pub enum InputKind { MacroEditor(Option), MacroBinding(i64), MacroPrompt(String), + CategoryValue { + item_id: i64, + category_id: i64, + kind: CategoryKind, + }, } #[derive(Debug, Clone)] @@ -56,7 +62,7 @@ pub struct InputState { #[derive(Debug, Clone)] pub struct PropsState { pub item_id: i64, - pub values: [String; 5], + pub values: [String; 4], pub field: usize, } @@ -104,6 +110,10 @@ pub enum Mode { Views, CategoryManager, Menu, + Execute { + selected: usize, + }, + RuleConflicts, Confirm { prompt: String, action: ConfirmAction, @@ -151,6 +161,7 @@ pub struct App { pub(crate) view_index: usize, pub(crate) items: Vec, pub(crate) selected: usize, + pub(crate) selected_column: usize, pub(crate) scroll: usize, pub(crate) search: String, pub(crate) marked: HashSet, @@ -161,6 +172,7 @@ pub struct App { pub(crate) status: String, pub(crate) should_quit: bool, pub(crate) item_rows: Vec<(Rect, i64)>, + pub(crate) item_columns: Vec<(Rect, usize)>, pub(crate) command_regions: Vec, pub(crate) view_regions: Vec, pub(crate) choice_regions: Vec, @@ -194,11 +206,12 @@ impl App { } fn build( - db: Database, + mut db: Database, path: PathBuf, preferences: AppPreferences, preferences_path: PathBuf, ) -> Result { + db.apply_rules_to_all()?; let views = db.views()?; let categories = db.categories()?; let document_settings = db.document_settings()?; @@ -214,6 +227,7 @@ impl App { view_index: 0, items: vec![], selected: 0, + selected_column: 0, scroll: 0, search: String::new(), marked: HashSet::new(), @@ -224,6 +238,7 @@ impl App { status: "Ready — capture first, organize later".into(), should_quit: false, item_rows: vec![], + item_columns: vec![], command_regions: vec![], view_regions: vec![], choice_regions: vec![], @@ -268,6 +283,9 @@ impl App { self.categories = self.db.categories()?; self.view_index = self.view_index.min(self.views.len().saturating_sub(1)); self.items = self.db.items(&self.views[self.view_index], &self.search)?; + self.selected_column = self + .selected_column + .min(self.current_view().columns.len().saturating_sub(1)); self.selected = keep .and_then(|id| self.items.iter().position(|i| i.id == id)) .unwrap_or(self.selected.min(self.items.len().saturating_sub(1))); @@ -396,6 +414,11 @@ impl App { Mode::Views => self.handle_views(key), Mode::CategoryManager => self.handle_category_manager(key), Mode::Menu => self.handle_menu(key), + Mode::Execute { .. } => self.handle_execute(key), + Mode::RuleConflicts => { + self.mode = Mode::Normal; + Ok(()) + } Mode::Confirm { .. } => self.handle_confirm(key), Mode::Choice(_) => self.handle_choice(key), Mode::MacroManager => self.handle_macro_manager(key), @@ -414,6 +437,8 @@ impl App { let today = Local::now().date_naive(); if today != self.current_day { self.current_day = today; + self.db.apply_rules_to_all()?; + self.refresh()?; if self.document_settings.trash_policy == TrashPolicy::EndOfDay { let count = self.db.empty_trash()?; if count > 0 { @@ -428,6 +453,20 @@ impl App { fn macro_context(&self) -> MacroContext { let now = Local::now(); let highlighted = self.selected_item(); + let column = self.current_view().columns.get(self.selected_column); + let (highlight_type, highlight_value) = match (highlighted, column) { + (Some(item), Some(column)) => { + let kind = match column.field.as_str() { + "numeric" | "value" | "priority" => "NUMERIC", + "date" | "when" | "done" | "alarm" | "created" | "updated" => "DATE", + "categories" => "CATEGORY", + "item" => "ITEM", + _ => "TEXT", + }; + (kind, column.raw_value(item)) + } + _ => ("NONE", String::new()), + }; MacroContext { date: now.format("%Y-%m-%d").to_string(), time: now.format("%H:%M:%S").to_string(), @@ -450,10 +489,8 @@ impl App { _ => "BOX", } .into(), - highlight_type: highlighted.map_or("NONE", |_| "ITEM").into(), - highlight_value: highlighted - .map(|item| item.text.clone()) - .unwrap_or_default(), + highlight_type: highlight_type.into(), + highlight_value, mark_count: self.marked.len(), marked_in_view: self .items @@ -493,8 +530,7 @@ impl App { fn current_prompt(&self) -> String { match &self.mode { Mode::Properties(properties) => { - ["Priority", "When", "Alarm", "Numeric value", "Recurrence"][properties.field] - .into() + ["Priority", "When", "Alarm", "Recurrence"][properties.field].into() } Mode::Form(form) => form_prompt(&form.kind, form.field).into(), Mode::Input(input) => input.title.clone(), @@ -869,6 +905,11 @@ impl App { KeyCode::Char('q') => self.should_quit = true, KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::Left => self.selected_column = self.selected_column.saturating_sub(1), + KeyCode::Right => { + self.selected_column = (self.selected_column + 1) + .min(self.current_view().columns.len().saturating_sub(1)) + } KeyCode::PageUp => self.move_selection(-10), KeyCode::PageDown => self.move_selection(10), KeyCode::Home => self.selected = 0, @@ -876,7 +917,7 @@ impl App { KeyCode::Insert | KeyCode::Char('n') => { self.open_input(InputKind::NewItem, "New item", String::new(), false) } - KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_selected(), + KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_highlighted(), KeyCode::F(3) | KeyCode::Char('c') => { if self.selected_item().is_some() { self.category_selected = 0; @@ -928,6 +969,7 @@ impl App { let len = self.views.len() as isize; self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize; self.selected = 0; + self.selected_column = 0; self.scroll = 0; self.search.clear(); self.refresh()?; @@ -944,14 +986,49 @@ impl App { cursor, }); } - fn edit_selected(&mut self) { - if let Some(i) = self.selected_item() { + fn edit_highlighted(&mut self) { + let Some(item) = self.selected_item().cloned() else { + return; + }; + let Some(column) = self + .current_view() + .columns + .get(self.selected_column) + .cloned() + else { + return; + }; + if matches!(column.field.as_str(), "numeric" | "value" | "date") + && let Some(category_name) = column.category + && let Some(category) = self + .categories + .iter() + .find(|category| category.name.eq_ignore_ascii_case(&category_name)) + .cloned() + { + let value = match category.kind { + CategoryKind::Numeric => item + .numeric_value_for(&category.name) + .map(|value| value.to_string()) + .unwrap_or_default(), + CategoryKind::Date => item + .date_value_for(&category.name) + .unwrap_or_default() + .into(), + CategoryKind::Standard => String::new(), + }; self.open_input( - InputKind::EditItem(i.id), - "Edit item", - i.text.clone(), + InputKind::CategoryValue { + item_id: item.id, + category_id: category.id, + kind: category.kind, + }, + &format!("{} value", category.name), + value, false, ); + } else { + self.open_input(InputKind::EditItem(item.id), "Edit item", item.text, false); } } fn edit_note(&mut self) { @@ -972,7 +1049,6 @@ impl App { i.priority.to_string(), i.when_at.clone().unwrap_or_default(), i.alarm_at.clone().unwrap_or_default(), - i.numeric_value.map(|v| v.to_string()).unwrap_or_default(), i.recurrence.clone(), ], field: 0, @@ -1091,7 +1167,7 @@ impl App { match input.kind { InputKind::NewItem => { self.db.add_item(&input.value)?; - self.status = "Item captured and automatically filed".into(); + self.status = "Item captured and conditions/actions evaluated".into(); } InputKind::EditItem(id) => { self.db.update_item( @@ -1170,6 +1246,33 @@ impl App { self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); return Ok(()); } + InputKind::CategoryValue { + item_id, + category_id, + kind, + } => match kind { + CategoryKind::Numeric => { + let value = if input.value.trim().is_empty() { + None + } else { + Some( + input + .value + .trim() + .parse::() + .context("numeric category value must be a number")?, + ) + }; + self.db.set_numeric_value(item_id, category_id, value)?; + self.status = "Numeric category value saved".into(); + } + CategoryKind::Date => { + let value = normalize_date(&self.db, &input.value)?; + self.db.set_date_value(item_id, category_id, value)?; + self.status = "Date category value saved".into(); + } + CategoryKind::Standard => bail!("standard categories do not hold values"), + }, } self.mode = Mode::Normal; self.refresh()?; @@ -1185,8 +1288,8 @@ impl App { self.mode = Mode::Normal; return Ok(()); } - KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5, - KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5, + KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 4, + KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 3) % 4, KeyCode::Backspace => { props.values[props.field].pop(); } @@ -1200,18 +1303,9 @@ impl App { } let when_at = normalize_date(&self.db, &props.values[1])?; let alarm_at = normalize_date(&self.db, &props.values[2])?; - let numeric_value = if props.values[3].trim().is_empty() { - None - } else { - Some( - props.values[3] - .parse::() - .context("numeric value must be a number")?, - ) - }; - Ok((priority, when_at, alarm_at, numeric_value)) + Ok((priority, when_at, alarm_at)) })(); - let (priority, when_at, alarm_at, numeric_value) = match parsed { + let (priority, when_at, alarm_at) = match parsed { Ok(values) => values, Err(error) => { self.status = format!("Cannot save properties: {error:#}"); @@ -1225,8 +1319,7 @@ impl App { priority: Some(priority), when_at: Some(when_at), alarm_at: Some(alarm_at), - numeric_value: Some(numeric_value), - recurrence: Some(props.values[4].clone()), + recurrence: Some(props.values[3].clone()), ..Default::default() }, )?; @@ -1373,6 +1466,8 @@ impl App { KeyCode::Char('p') => self.open_preferences_form(), KeyCode::Char('d') => self.open_document_settings_form(), KeyCode::Char('x') => self.mode = Mode::MacroManager, + KeyCode::Char('u') => self.mode = Mode::Execute { selected: 0 }, + KeyCode::Char('f') => self.mode = Mode::RuleConflicts, KeyCode::Char('b') => { let path = self.db.backup_now()?; self.status = format!("Backup written to {}", path.display()); @@ -1393,6 +1488,60 @@ impl App { Ok(()) } + fn handle_execute(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Execute { mut selected } = self.mode else { + return Ok(()); + }; + match key.code { + KeyCode::Esc => self.mode = Mode::Normal, + KeyCode::Up | KeyCode::Char('k') => { + selected = selected.saturating_sub(1); + self.mode = Mode::Execute { selected }; + } + KeyCode::Down | KeyCode::Char('j') => { + selected = (selected + 1).min(4); + self.mode = Mode::Execute { selected }; + } + KeyCode::Enter | KeyCode::Char(' ') => { + let ids = match selected { + 0 => self + .selected_item() + .map(|item| vec![item.id]) + .unwrap_or_default(), + 1 => self.marked.iter().copied().collect(), + 2 => { + let selected_item = self.selected_item(); + let section = self.current_view().sections.iter().find(|section| { + selected_item.is_some_and(|item| { + filter::matches(§ion.filter_expr, item).unwrap_or(false) + }) + }); + self.items + .iter() + .filter(|item| { + section.is_some_and(|section| { + filter::matches(§ion.filter_expr, item).unwrap_or(false) + }) + }) + .map(|item| item.id) + .collect() + } + 3 => self.items.iter().map(|item| item.id).collect(), + _ => self.db.all_item_ids()?, + }; + let summary = self.db.execute_rules(&ids)?; + self.refresh()?; + self.mode = Mode::Normal; + self.status = format!( + "Executed conditions/actions for {} item(s) — {} conflict(s)", + summary.items, summary.conflicts + ); + } + _ => self.mode = Mode::Execute { selected }, + } + Ok(()) + } + fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> { let Mode::Confirm { action, .. } = self.mode.clone() else { return Ok(()); @@ -1483,6 +1632,7 @@ impl App { settings.afternoon_time.clone(), settings.evening_time.clone(), settings.note_tab_width.to_string(), + yes_no(settings.report_headers), ], field: 0, }); @@ -1701,6 +1851,7 @@ impl App { note_tab_width: form.values[11] .parse() .context("note tab width must be a number")?, + report_headers: parse_yes_no(&form.values[12])?, }; self.db.save_document_settings(&settings)?; self.document_settings = settings; @@ -1856,6 +2007,13 @@ impl App { if let Some(pos) = self.items.iter().position(|i| i.id == *id) { self.selected = pos; } + if let Some((_, column)) = self + .item_columns + .iter() + .find(|(region, _)| inside(*region, x, y)) + { + self.selected_column = *column; + } return Ok(()); } if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) { @@ -2092,7 +2250,6 @@ mod tests { "not-a-priority".into(), "not-a-date".into(), String::new(), - "not-a-number".into(), String::new(), ], field: 0, @@ -2106,4 +2263,55 @@ mod tests { assert!(app.items[0].when_at.is_none()); assert!(app.items[0].numeric_value.is_none()); } + + #[test] + fn highlighted_numeric_cells_feed_macros_and_edit_the_bound_category() { + let directory = tempdir().unwrap(); + let path = directory.path().join("cell-context.agnd"); + let mut db = Database::open(&path).unwrap(); + let hours = db + .save_category(None, "Hours", None, "numeric", "", false) + .unwrap(); + let item = db.add_item("Consulting").unwrap(); + db.set_numeric_value(item, hours, Some(12.5)).unwrap(); + let view_id = db + .save_view_design( + None, + "Hours", + "list", + "", + "manual", + true, + "", + "item:60:Item,numeric[Hours]:20:Hours", + "", + ) + .unwrap(); + let mut app = App::new_with_preferences( + db, + path, + AppPreferences::default(), + directory.path().join("preferences.toml"), + ) + .unwrap(); + app.view_index = app + .views + .iter() + .position(|view| view.id == view_id) + .unwrap(); + app.refresh().unwrap(); + app.handle_key(key(KeyCode::Right)).unwrap(); + let context = app.macro_context(); + assert_eq!(context.highlight_type, "NUMERIC"); + assert_eq!(context.highlight_value, "12.5"); + + app.handle_key(key(KeyCode::F(2))).unwrap(); + let Mode::Input(mut input) = app.mode.clone() else { + panic!("numeric cell did not open a value editor"); + }; + input.value = "20".into(); + input.cursor = 2; + app.accept_input(input).unwrap(); + assert_eq!(app.items[0].numeric_value_for("Hours"), Some(20.0)); + } } diff --git a/src/app/forms.rs b/src/app/forms.rs index 5d06b60..df814eb 100644 --- a/src/app/forms.rs +++ b/src/app/forms.rs @@ -56,7 +56,7 @@ pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option return None, }, FormKind::DocumentSettings => match field { - 1 | 4 => YES_NO_CHOICES, + 1 | 4 | 12 => YES_NO_CHOICES, 2 => &[ ("On demand", "on-demand"), ("On close", "on-close"), @@ -135,6 +135,7 @@ pub(super) fn form_prompt(kind: &FormKind, field: usize) -> &'static str { "Afternoon time", "Evening time", "Note tab width", + "Report headers and footers", ], }; prompts.get(field).copied().unwrap_or("") diff --git a/src/db.rs b/src/db.rs index f353f8f..977d5fc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,8 +12,9 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::date::{DateParseConfig, extract_when_configured, next_occurrence}; use crate::filter; use crate::model::{ - Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, - RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection, + Aggregate, Category, CategoryKind, CategoryRule, CategoryValue, DocumentSettings, DonePolicy, + Item, ItemChanges, MacroDef, RuleActionKind, TrashPolicy, ViewColumn, ViewDef, ViewKind, + ViewSection, }; mod export; @@ -50,6 +51,19 @@ pub struct Database { new_document: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleConflict { + pub item_id: i64, + pub item_text: String, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExecuteSummary { + pub items: usize, + pub conflicts: usize, +} + impl Database { pub fn open(path: &Path) -> Result { let existed = path.exists() && path.metadata().is_ok_and(|metadata| metadata.len() > 0); @@ -168,7 +182,11 @@ impl Database { [], )?; tx.execute( - "INSERT INTO categories(name, kind, sort_order) VALUES('Priority', 'numeric', 4)", + "INSERT INTO categories(name, kind, sort_order) VALUES('Priority', 'standard', 4)", + [], + )?; + tx.execute( + "INSERT INTO categories(name, kind, sort_order) VALUES('Value', 'numeric', 5)", [], )?; } @@ -226,6 +244,7 @@ impl Database { &defaults.note_tab_width.to_string(), )? .parse()?, + report_headers: self.meta_bool("document.report_headers", defaults.report_headers)?, }; validate_document_settings(&settings)?; Ok(settings) @@ -256,6 +275,10 @@ impl Database { "document.note_tab_width", settings.note_tab_width.to_string(), ), + ( + "document.report_headers", + settings.report_headers.to_string(), + ), ] { tx.execute( "INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", @@ -324,7 +347,7 @@ impl Database { name: r.get(1)?, kind: enum_column(r, 2)?, filter_value: r.get(3)?, - sort_key: enum_column(r, 4)?, + sort_key: r.get(4)?, show_done: r.get::<_, i64>(5)? != 0, filter_expr: r.get(6)?, columns: vec![], @@ -341,14 +364,22 @@ impl Database { } fn view_columns(&self, view_id: i64) -> Result> { - let mut stmt=self.conn.prepare("SELECT field,heading,width,aggregate FROM view_columns WHERE view_id=?1 ORDER BY sort_order,id")?; + let mut stmt=self.conn.prepare("SELECT vc.field,c.name,vc.heading,vc.width,vc.aggregate,vc.number_label,vc.decimals,vc.decimal_separator,vc.thousands_separator,vc.negative_style,vc.percent_total,vc.alignment FROM view_columns vc LEFT JOIN categories c ON c.id=vc.category_id WHERE vc.view_id=?1 ORDER BY vc.sort_order,vc.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: enum_column(r, 3)?, + category: r.get(1)?, + heading: r.get(2)?, + width: r.get::<_, u16>(3)?, + aggregate: enum_column(r, 4)?, + number_label: r.get(5)?, + decimals: r.get(6)?, + decimal_separator: r.get(7)?, + thousands_separator: r.get(8)?, + negative_style: r.get(9)?, + percent_total: r.get::<_, i64>(10)? != 0, + alignment: r.get(11)?, }) })? .collect::>>()?; @@ -410,6 +441,21 @@ impl Database { if id.is_some() && id == parent_id { bail!("a category cannot be its own parent"); } + if let Some(id) = id { + let current_kind: String = + self.conn + .query_row("SELECT kind FROM categories WHERE id=?1", [id], |row| { + row.get(0) + })?; + let has_values: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM item_values WHERE category_id=?1)", + [id], + |row| row.get(0), + )?; + if has_values && current_kind != kind { + bail!("remove the category's values before changing its kind"); + } + } if let (Some(id), Some(parent)) = (id, parent_id) { let cycle: bool = self.conn.query_row( "WITH RECURSIVE descendants(id) AS ( @@ -464,10 +510,20 @@ impl Database { action_spec: &str, ) -> Result<()> { if condition_expr.trim().is_empty() && action_spec.trim().is_empty() { - self.conn.execute( + let tx = self.conn.unchecked_transaction()?; + tx.execute( "DELETE FROM category_rules WHERE category_id=?1", [category_id], )?; + tx.execute( + "DELETE FROM item_categories WHERE category_id=?1 AND assignment='conditional'", + [category_id], + )?; + tx.execute( + "DELETE FROM item_values WHERE category_id=?1 AND assignment='conditional'", + [category_id], + )?; + tx.commit()?; return Ok(()); } filter::parse(condition_expr).context("invalid category rule condition")?; @@ -477,7 +533,32 @@ impl Database { "DELETE FROM category_rules WHERE category_id=?1", [category_id], )?; + if condition_expr.trim().is_empty() { + tx.execute( + "DELETE FROM item_categories WHERE category_id=?1 AND assignment='conditional'", + [category_id], + )?; + tx.execute( + "DELETE FROM item_values WHERE category_id=?1 AND assignment='conditional'", + [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.as_str(),value])?; + let rule_id = tx.last_insert_rowid(); + if kind != RuleActionKind::None { + tx.execute( + "INSERT OR IGNORE INTO rule_action_runs(rule_id,item_id) + SELECT ?1,positive.item_id FROM item_categories positive + WHERE positive.category_id=?2 AND positive.assignment<>'excluded' + 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' + )", + params![rule_id, category_id], + )?; + } tx.commit()?; Ok(()) } @@ -508,12 +589,26 @@ impl Database { if !matches!( sort_key, "manual" | "when" | "done" | "priority" | "updated" - ) { + ) && parse_bound_field(sort_key).is_none() + { 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)?; + if let Some((field, Some(category))) = parse_bound_field(sort_key) { + let kind: String = self + .conn + .query_row( + "SELECT kind FROM categories WHERE name=?1 COLLATE NOCASE", + [&category], + |row| row.get(0), + ) + .with_context(|| format!("sort category {category:?} does not exist"))?; + if kind != field { + bail!("sort category {category:?} is {kind}, not {field}"); + } + } 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])?; @@ -524,7 +619,37 @@ impl Database { }; 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.as_str(),order as i64])?; + let category_id = column + .category + .as_deref() + .map(|name| { + tx.query_row( + "SELECT id FROM categories WHERE name=?1 COLLATE NOCASE", + [name], + |row| row.get::<_, i64>(0), + ) + .with_context(|| format!("view column category {name:?} does not exist")) + }) + .transpose()?; + if let Some(category_id) = category_id { + let category_kind: String = tx.query_row( + "SELECT kind FROM categories WHERE id=?1", + [category_id], + |row| row.get(0), + )?; + if matches!(column.field.as_str(), "numeric" | "date") + && category_kind != column.field + { + bail!( + "{} column category {:?} is {}, not {}", + column.field, + column.category, + category_kind, + column.field + ); + } + } + tx.execute("INSERT INTO view_columns(view_id,field,category_id,heading,width,aggregate,number_label,decimals,decimal_separator,thousands_separator,negative_style,percent_total,alignment,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",params![view_id,column.field,category_id,column.heading,column.width,column.aggregate.as_str(),column.number_label,column.decimals,column.decimal_separator,column.thousands_separator,column.negative_style,column.percent_total as i64,column.alignment,order as i64])?; } tx.execute("DELETE FROM view_sections WHERE view_id=?1", [view_id])?; for (order, section) in sections.iter().enumerate() { @@ -627,9 +752,7 @@ impl Database { Self::auto_assign_tx(&tx, id, text)?; } tx.commit()?; - if settings.automatic_filing { - self.apply_rules_to_item(id)?; - } + self.apply_rules_to_item(id)?; Ok(id) } @@ -676,9 +799,7 @@ impl Database { } } tx.commit()?; - if automatic_filing { - self.apply_rules_to_all()?; - } + self.apply_rules_to_all()?; Ok(()) } @@ -721,12 +842,6 @@ impl Database { 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 { @@ -746,9 +861,10 @@ impl Database { )?; } tx.commit()?; - if automatic_filing { - self.apply_rules_to_item(id)?; + if let Some(value) = changes.numeric_value { + self.set_legacy_numeric_value(id, value)?; } + self.apply_rules_to_item(id)?; Ok(()) } @@ -782,6 +898,58 @@ impl Database { Ok(()) } + pub fn execute_rules(&mut self, ids: &[i64]) -> Result { + for id in ids { + self.conn + .execute("DELETE FROM rule_action_runs WHERE item_id=?1", [id])?; + self.apply_rules_to_item(*id)?; + } + let conflicts = if ids.is_empty() { + 0 + } else { + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let mut statement = self.conn.prepare(&format!( + "SELECT COUNT(*) FROM rule_conflicts WHERE item_id IN ({placeholders})" + ))?; + let values = ids + .iter() + .map(|id| id as &dyn rusqlite::ToSql) + .collect::>(); + statement.query_row(values.as_slice(), |row| row.get::<_, i64>(0))? as usize + }; + Ok(ExecuteSummary { + items: ids.len(), + conflicts, + }) + } + + pub fn all_item_ids(&self) -> Result> { + let mut statement = self + .conn + .prepare("SELECT id FROM items WHERE discarded=0 ORDER BY sort_order,id")?; + Ok(statement + .query_map([], |row| row.get(0))? + .collect::>()?) + } + + pub fn rule_conflicts(&self) -> Result> { + let mut statement = self.conn.prepare( + "SELECT c.item_id,i.text,c.message FROM rule_conflicts c + JOIN items i ON i.id=c.item_id ORDER BY c.updated_at DESC", + )?; + Ok(statement + .query_map([], |row| { + Ok(RuleConflict { + item_id: row.get(0)?, + item_text: row.get(1)?, + message: row.get(2)?, + }) + })? + .collect::>()?) + } + pub fn count_matching_filter(&self, source: &str) -> Result { let expr = filter::parse(source)?; let mut stmt = self @@ -805,50 +973,207 @@ impl Database { 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 { + let Some(mut 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 !rule.condition_expr.trim().is_empty() { + let expr = filter::parse(&rule.condition_expr)?; + let matched = filter::evaluate(&expr, &item); + changed |= + self.set_conditional_assignment(item_id, rule.category_id, matched)?; + } + } + item = self.load_item(item_id)?.unwrap_or(item); + for rule in &rules { + if rule.action_kind == RuleActionKind::None { + continue; + } + let source_assigned = item + .categories + .iter() + .any(|category| category.id == rule.category_id); + let ran: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM rule_action_runs WHERE rule_id=?1 AND item_id=?2)", + params![rule.id, item_id], + |row| row.get(0), + )?; + if source_assigned && !ran { + changed |= self.apply_rule_action(item_id, rule, &item)?; + self.conn.execute( + "INSERT OR IGNORE INTO rule_action_runs(rule_id,item_id) VALUES(?1,?2)", + params![rule.id, item_id], + )?; + } else if !source_assigned && ran { + self.conn.execute( + "DELETE FROM rule_action_runs WHERE rule_id=?1 AND item_id=?2", + params![rule.id, item_id], + )?; } } if !changed { + self.conn + .execute("DELETE FROM rule_conflicts WHERE item_id=?1", [item_id])?; return Ok(()); } } - bail!("category rules did not converge after 16 passes") + self.conn.execute( + "INSERT INTO rule_conflicts(item_id,message,updated_at) VALUES(?1,?2,?3) + ON CONFLICT(item_id) DO UPDATE SET message=excluded.message,updated_at=excluded.updated_at", + params![item_id,"category conditions did not converge after 16 passes",Local::now().to_rfc3339()], + )?; + Ok(()) } - fn apply_rule(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result { + fn set_conditional_assignment( + &self, + item_id: i64, + category_id: i64, + matched: bool, + ) -> Result { + let exists: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='conditional')", + params![item_id,category_id], + |row| row.get(0), + )?; + if matched == exists { + return Ok(false); + } + if matched { + self.conn.execute( + "INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'conditional')", + params![item_id,category_id], + )?; + } else { + self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='conditional'", + params![item_id,category_id], + )?; + self.conn.execute( + "DELETE FROM item_values WHERE item_id=?1 AND category_id=?2 AND assignment='conditional'", + params![item_id,category_id], + )?; + } + Ok(true) + } + + fn apply_rule_action(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result { match rule.action_kind { + RuleActionKind::None => Ok(false), RuleActionKind::Assign | RuleActionKind::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 target = self.action_category(rule.category_id, &rule.action_value)?; let Some(target) = target else { return Ok(false); }; - let state:Option=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 == RuleActionKind::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])?; + let exists: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='explicit')",params![item_id,target],|row|row.get(0))?; + if !exists { + self.conn.execute("DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='excluded'",params![item_id,target])?; + self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'explicit')",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])?; + } else { + let exists: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='excluded')",params![item_id,target],|row|row.get(0))?; + if !exists { + self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'excluded')",params![item_id,target])?; + self.conn.execute( + "DELETE FROM item_values WHERE item_id=?1 AND category_id=?2", + params![item_id, target], + )?; + return Ok(true); + } + } + Ok(false) + } + RuleActionKind::Remove => { + let Some(target) = self.action_category(rule.category_id, &rule.action_value)? + else { + return Ok(false); + }; + let changed = self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='explicit'", + params![item_id,target], + )? > 0; + self.conn.execute( + "DELETE FROM item_values WHERE item_id=?1 AND category_id=?2", + params![item_id, target], + )?; + let excluded = self.conn.execute( + "INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'excluded')", + params![item_id,target], + )? > 0; + Ok(changed || excluded) + } + RuleActionKind::Numeric => { + let (category, raw_value) = split_category_value(&rule.action_value)?; + let target = self + .action_category(rule.category_id, category)? + .context("numeric action category does not exist")?; + let value = raw_value.parse::()?; + let changed = item.numeric_value_for(category) != Some(value); + if changed { + self.set_category_value(item_id, target, Some(value), None, "explicit")?; + } + Ok(changed) + } + RuleActionKind::Date => { + let (category, raw_value) = split_category_value(&rule.action_value)?; + let target = self + .action_category(rule.category_id, category)? + .context("date action category does not exist")?; + let value = self + .interpret_date(raw_value)? + .unwrap_or_else(|| raw_value.to_owned()); + let changed = item.date_value_for(category) != Some(value.as_str()); + if changed { + self.set_category_value(item_id, target, None, Some(&value), "explicit")?; + } + Ok(changed) + } + RuleActionKind::Value => { + let value = rule.action_value.parse::()?; + let target = self + .conn + .query_row( + "SELECT id FROM categories WHERE id=?1 AND kind='numeric'", + [rule.category_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .or_else(|| { + self.conn + .query_row( + "SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .ok() + .flatten() + }) + .context("value action needs a numeric category")?; + let changed = item + .values + .iter() + .find(|entry| entry.category_id == target) + .and_then(|entry| entry.numeric_value) + != Some(value); + if changed { + self.set_category_value(item_id, target, Some(value), None, "explicit")?; + } + Ok(changed) + } + RuleActionKind::Export => { + self.export_rule_item(item, &rule.action_value)?; + Ok(false) + } + RuleActionKind::Discard => { + if !item.discarded && parse_bool(&rule.action_value) { + self.conn.execute( + "UPDATE items SET discarded=1,updated_at=?1 WHERE id=?2", + params![Local::now().to_rfc3339(), item_id], + )?; return Ok(true); } Ok(false) @@ -865,18 +1190,6 @@ impl Database { Ok(false) } } - RuleActionKind::Value => { - let value = rule.action_value.parse::()?; - 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) - } - } RuleActionKind::When => { let value = self .interpret_date(&rule.action_value)? @@ -940,10 +1253,63 @@ impl Database { } } + fn action_category(&self, source: i64, name: &str) -> Result> { + if name.trim().is_empty() { + return Ok(Some(source)); + } + Ok(self + .conn + .query_row( + "SELECT id FROM categories WHERE name=?1 COLLATE NOCASE", + [name.trim()], + |row| row.get(0), + ) + .optional()?) + } + + fn export_rule_item(&self, item: &Item, specification: &str) -> Result<()> { + use std::io::Write; + + let (format, path) = specification + .split_once('|') + .context("export action must use format|path")?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path.trim()) + .with_context(|| format!("could not open rule export {}", path.trim()))?; + match format.trim().to_lowercase().as_str() { + "jsonl" | "json" => writeln!( + file, + "{{\"id\":{},\"text\":{},\"note\":{},\"categories\":{}}}", + item.id, + json(&item.text), + json(&item.note), + json(&item.category_names()) + )?, + "csv" => writeln!( + file, + "{},{},{},{}", + item.id, + csv(&item.text), + csv(&item.note), + csv(&item.category_names()) + )?, + _ => bail!("rule export format must be jsonl or csv"), + } + Ok(()) + } + fn load_item(&self, id: i64) -> Result> { - 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()?; + 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![],values:vec![]})).optional()?; if let Some(item) = &mut item { item.categories = self.categories_for_item(id)?; + item.values = self.values_for_item(id)?; + item.numeric_value = item + .values + .iter() + .find_map(|value| value.numeric_value) + .or(item.numeric_value); } Ok(item) } @@ -971,21 +1337,21 @@ impl Database { 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])?; + tx.execute("INSERT INTO items(text,note,priority,when_at,alarm_at,recurrence,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?7,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))",params![&item.text,&item.note,item.priority,next_when,next_alarm,&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])?; + tx.execute("INSERT OR REPLACE INTO item_values(item_id,category_id,numeric_value,date_value,assignment) SELECT ?1,category_id,numeric_value,date_value,assignment FROM item_values WHERE item_id=?2 AND assignment='explicit'",params![created,id])?; next_id = Some(created); } let discard_completed = item.done_at.is_none() && settings.done_policy == DonePolicy::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(*id)?; + if let Some(next_id) = next_id { self.apply_rules_to_item(next_id)?; } } @@ -1009,42 +1375,58 @@ impl Database { } pub fn toggle_assignment(&mut self, item_id: i64, category_id: i64) -> Result<()> { - let state: Option = self - .conn - .query_row( - "SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2", + let explicit: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='explicit')", + params![item_id,category_id], |row| row.get(0))?; + let excluded: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='excluded')", + params![item_id,category_id], |row| row.get(0))?; + let inferred: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment IN ('automatic','conditional'))", + params![item_id,category_id], |row| row.get(0))?; + if excluded { + self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='excluded'", params![item_id, category_id], + )?; + } else if explicit || inferred { + self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='explicit'", + params![item_id, category_id], + )?; + self.conn.execute( + "INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'excluded')", + params![item_id, category_id], + )?; + self.conn.execute( + "DELETE FROM item_values WHERE item_id=?1 AND category_id=?2", + params![item_id, category_id], + )?; + } else { + let exclusive: bool = self.conn.query_row( + "SELECT exclusive FROM categories WHERE id=?1", + [category_id], |r| r.get(0), - ) - .optional()?; - match state.as_deref() { - Some("excluded") => { + )?; + if exclusive { self.conn.execute( - "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2", - params![item_id, category_id], + "DELETE FROM item_values 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( + "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], )?; } - 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])?; - } + self.conn.execute("INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'explicit')", params![item_id,category_id])?; } + self.apply_rules_to_item(item_id)?; Ok(()) } pub fn item_category_ids(&self, item_id: i64) -> Result> { let mut stmt = self.conn.prepare( - "SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded'", + "SELECT DISTINCT category_id FROM item_categories positive WHERE item_id=?1 AND assignment<>'excluded' 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(stmt .query_map([item_id], |r| r.get(0))? @@ -1060,6 +1442,89 @@ impl Database { .collect::>()?) } + pub fn set_numeric_value( + &mut self, + item_id: i64, + category_id: i64, + value: Option, + ) -> Result<()> { + self.set_category_value(item_id, category_id, value, None, "explicit")?; + self.apply_rules_to_item(item_id) + } + + pub fn set_date_value( + &mut self, + item_id: i64, + category_id: i64, + value: Option, + ) -> Result<()> { + self.set_category_value(item_id, category_id, None, value.as_deref(), "explicit")?; + self.apply_rules_to_item(item_id) + } + + fn set_legacy_numeric_value(&self, item_id: i64, value: Option) -> Result<()> { + let category_id = self + .conn + .query_row( + "SELECT c.id FROM categories c + LEFT JOIN item_categories ic ON ic.category_id=c.id AND ic.item_id=?1 AND ic.assignment<>'excluded' + WHERE c.kind='numeric' ORDER BY ic.item_id IS NULL,c.id LIMIT 1", + [item_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .context("a numeric category is required before setting a numeric value")?; + self.set_category_value(item_id, category_id, value, None, "explicit") + } + + fn set_category_value( + &self, + item_id: i64, + category_id: i64, + numeric_value: Option, + date_value: Option<&str>, + assignment: &str, + ) -> Result<()> { + let kind: CategoryKind = self.conn.query_row( + "SELECT kind FROM categories WHERE id=?1", + [category_id], + |row| enum_column(row, 0), + )?; + if numeric_value.is_some() && kind != CategoryKind::Numeric { + bail!("numeric values require a numeric category"); + } + if date_value.is_some() && kind != CategoryKind::Date { + bail!("date values require a date category"); + } + if numeric_value.is_none() && date_value.is_none() { + self.conn.execute( + "DELETE FROM item_values WHERE item_id=?1 AND category_id=?2", + params![item_id, category_id], + )?; + return Ok(()); + } + self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2 AND assignment='excluded'", + params![item_id, category_id], + )?; + self.conn.execute( + "INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,?3)", + params![item_id, category_id, assignment], + )?; + self.conn.execute( + "INSERT INTO item_values(item_id,category_id,numeric_value,date_value,assignment) + VALUES(?1,?2,?3,?4,?5) + ON CONFLICT(item_id,category_id) DO UPDATE SET + numeric_value=excluded.numeric_value,date_value=excluded.date_value,assignment=excluded.assignment", + params![item_id, category_id, numeric_value, date_value, assignment], + )?; + self.conn.execute( + "UPDATE items SET updated_at=?1 WHERE id=?2", + params![Local::now().to_rfc3339(), item_id], + )?; + Ok(()) + } + pub fn items(&self, view: &ViewDef, search: &str) -> Result> { 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", @@ -1100,12 +1565,12 @@ impl Database { let like = format!("%{search}%"); values.extend([like.clone(), like]); } - sql.push_str(match view.sort_key { - SortKey::When => " ORDER BY i.when_at IS NULL,i.when_at,i.priority,i.sort_order", - SortKey::Done => " ORDER BY i.done_at DESC,i.sort_order", - SortKey::Priority => " ORDER BY i.priority,i.when_at IS NULL,i.when_at", - SortKey::Updated => " ORDER BY i.updated_at DESC", - SortKey::Manual => " ORDER BY i.sort_order,i.id", + 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> = @@ -1125,19 +1590,61 @@ impl Database { discarded: r.get::<_, i64>(10)? != 0, recurrence: r.get(11)?, categories: vec![], + values: vec![], }) })?; let mut items: Vec = rows.collect::>()?; for item in &mut items { item.categories = self.categories_for_item(item.id)?; + item.values = self.values_for_item(item.id)?; + item.numeric_value = item + .values + .iter() + .find_map(|value| value.numeric_value) + .or(item.numeric_value); } 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)); } + if let Some((field, Some(category))) = parse_bound_field(&view.sort_key) { + items.sort_by(|left, right| match field.as_str() { + "numeric" => compare_optional( + left.numeric_value_for(&category), + right.numeric_value_for(&category), + |left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal), + ), + "date" => compare_optional( + left.date_value_for(&category), + right.date_value_for(&category), + Ord::cmp, + ), + _ => std::cmp::Ordering::Equal, + }); + } Ok(items) } + fn values_for_item(&self, item_id: i64) -> Result> { + let mut statement = self.conn.prepare( + "SELECT v.category_id,c.name,c.kind,v.numeric_value,v.date_value,v.assignment + FROM item_values v JOIN categories c ON c.id=v.category_id + WHERE v.item_id=?1 ORDER BY c.name COLLATE NOCASE", + )?; + Ok(statement + .query_map([item_id], |row| { + Ok(CategoryValue { + category_id: row.get(0)?, + category_name: row.get(1)?, + kind: enum_column(row, 2)?, + numeric_value: row.get(3)?, + date_value: row.get(4)?, + assignment: row.get(5)?, + }) + })? + .collect::>()?) + } + fn categories_for_item(&self, item_id: i64) -> Result> { let mut stmt = self.conn.prepare("WITH RECURSIVE inherited(id) AS ( SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded' @@ -1302,14 +1809,40 @@ impl Database { }; 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::>().join(",\n"); + let records = items.iter().map(|i| { + let legacy_value = i.numeric_value.or_else(|| { + i.values.iter().find_map(|value| value.numeric_value) + }); + let values=i.values.iter().map(|value|format!("{}:{}",json(&value.category_name),value.numeric_value.map(|number|number.to_string()).or_else(||value.date_value.as_ref().map(|date|json(date))).unwrap_or_else(||"null".into()))).collect::>().join(","); + format!(" {{\"id\":{},\"text\":{},\"note\":{},\"priority\":{},\"when\":{},\"done\":{},\"alarm\":{},\"value\":{},\"values\":{{{}}},\"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()),legacy_value.map(|value|value.to_string()).unwrap_or_else(||"null".into()),values,json(&i.recurrence),json(&i.category_names())) + }).collect::>().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"); + let mut s = String::from( + "id,text,note,priority,when,done,alarm,value,values,recurrence,categories\n", + ); for i in &items { + let legacy_value = i + .numeric_value + .or_else(|| i.values.iter().find_map(|value| value.numeric_value)); + let values = i + .values + .iter() + .map(|value| { + format!( + "{}={}", + value.category_name, + value + .numeric_value + .map(|number| number.to_string()) + .or_else(|| value.date_value.clone()) + .unwrap_or_default() + ) + }) + .collect::>() + .join("; "); s.push_str(&format!( - "{},{},{},{},{},{},{},{},{},{}\n", + "{},{},{},{},{},{},{},{},{},{},{}\n", i.id, csv(&i.text), csv(&i.note), @@ -1317,16 +1850,31 @@ impl Database { 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(), + legacy_value + .map(|value| value.to_string()) + .unwrap_or_default(), + csv(&values), csv(&i.recurrence), csv(&i.category_names()) )); } s } else if matches!(format.to_lowercase().as_str(), "md" | "markdown") { - render_markdown(&view, &items) + let settings = self.document_settings()?; + render_markdown( + &view, + &items, + &settings.description, + settings.report_headers, + ) } else if format.eq_ignore_ascii_case("html") { - render_html(&view, &items) + let settings = self.document_settings()?; + render_html( + &view, + &items, + &settings.description, + settings.report_headers, + ) } else if matches!(format.to_lowercase().as_str(), "ics" | "ical") { render_ical(&items) } else { @@ -1357,6 +1905,19 @@ impl Database { } } +fn compare_optional( + left: Option, + right: Option, + compare: impl FnOnce(&T, &T) -> std::cmp::Ordering, +) -> std::cmp::Ordering { + match (left, right) { + (Some(left), Some(right)) => compare(&left, &right), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } +} + fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> Option { let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?.naive_local(); let old = DateTime::parse_from_rfc3339(old_when?).ok()?.naive_local(); @@ -1376,14 +1937,30 @@ fn default_columns() -> Vec { .into_iter() .map(|(field, heading, width)| ViewColumn { field: field.into(), + category: None, heading: heading.into(), width, aggregate: Aggregate::None, + number_label: String::new(), + decimals: 2, + decimal_separator: ".".into(), + thousands_separator: "comma".into(), + negative_style: "minus".into(), + percent_total: false, + alignment: if matches!(field, "priority") { + "right" + } else { + "left" + } + .into(), }) .collect() } fn parse_action_spec(spec: &str) -> Result<(RuleActionKind, String)> { + if spec.trim().is_empty() { + return Ok((RuleActionKind::None, String::new())); + } let (kind, value) = spec.trim().split_once(':').unwrap_or((spec.trim(), "")); let kind: RuleActionKind = kind .trim() @@ -1391,16 +1968,20 @@ fn parse_action_spec(spec: &str) -> Result<(RuleActionKind, String)> { .parse() .map_err(anyhow::Error::msg) .context( - "rule action must be assign, exclude, priority, value, when, alarm, repeat, or done", + "rule action must be assign, exclude, remove, priority, numeric, date, when, alarm, repeat, done, export, or discard", )?; if matches!( kind, RuleActionKind::Priority | RuleActionKind::Value + | RuleActionKind::Numeric + | RuleActionKind::Date | RuleActionKind::When | RuleActionKind::Alarm | RuleActionKind::Repeat | RuleActionKind::Done + | RuleActionKind::Export + | RuleActionKind::Discard ) && value.trim().is_empty() { bail!("rule action {kind} needs a value") @@ -1408,9 +1989,25 @@ fn parse_action_spec(spec: &str) -> Result<(RuleActionKind, String)> { if kind == RuleActionKind::Repeat { next_occurrence("2026-01-05T09:00:00+00:00", value.trim())?; } + if matches!(kind, RuleActionKind::Numeric | RuleActionKind::Date) { + split_category_value(value)?; + } + if kind == RuleActionKind::Export && !value.contains('|') { + bail!("export action must use export:jsonl|path or export:csv|path"); + } Ok((kind, value.trim().into())) } +fn split_category_value(source: &str) -> Result<(&str, &str)> { + let (category, value) = source + .split_once('=') + .context("category value actions must use Category=value")?; + if category.trim().is_empty() || value.trim().is_empty() { + bail!("category value actions must use Category=value"); + } + Ok((category.trim(), value.trim())) +} + fn parse_bool(value: &str) -> bool { matches!( value.trim().to_lowercase().as_str(), @@ -1441,7 +2038,9 @@ fn parse_columns_spec(spec: &str) -> Result> { 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(); + let raw_field = parts[0].trim(); + let (field, category) = + parse_bound_field(raw_field).unwrap_or_else(|| (raw_field.to_lowercase(), None)); if !matches!( field.as_str(), "item" @@ -1450,6 +2049,8 @@ fn parse_columns_spec(spec: &str) -> Result> { | "priority" | "note" | "value" + | "numeric" + | "date" | "done" | "alarm" | "recurrence" @@ -1474,11 +2075,68 @@ fn parse_columns_spec(spec: &str) -> Result> { .unwrap_or_else(|| "none".into()) .parse() .map_err(anyhow::Error::msg)?; + if aggregate != Aggregate::None + && !matches!(field.as_str(), "numeric" | "value" | "priority") + { + bail!("aggregate {aggregate} needs a numeric column"); + } + if matches!(field.as_str(), "numeric" | "date") && category.is_none() { + bail!("{field} columns must name a category, for example {field}[Hours]"); + } + let number_label = parts.get(4).map_or("", |value| value.trim()).to_owned(); + let decimals = parts + .get(5) + .map_or(Ok(2), |value| value.trim().parse::()) + .context("numeric column decimal places must be 0 through 15")?; + if decimals > 15 { + bail!("numeric column decimal places must be 0 through 15"); + } + let decimal_separator = match parts.get(6).map_or("dot", |value| value.trim()) { + "dot" | "." => ".", + "comma" => ",", + _ => bail!("numeric column decimal separator must be dot or comma"), + }; + let thousands_separator = parts.get(7).map_or("comma", |value| value.trim()); + if !matches!(thousands_separator, "comma" | "dot" | "space" | "none") { + bail!("numeric column thousands separator must be comma, dot, space, or none"); + } + if matches!( + (decimal_separator, thousands_separator), + (",", "comma") | (".", "dot") + ) { + bail!("numeric column decimal and thousands separators must differ"); + } + let negative_style = parts.get(8).map_or("minus", |value| value.trim()); + if !matches!(negative_style, "minus" | "parentheses" | "trailing") { + bail!("numeric column negative style must be minus, parentheses, or trailing"); + } + let percent_total = parts + .get(9) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("percent")); + let alignment = parts.get(10).map_or( + if matches!(field.as_str(), "numeric" | "value" | "priority") { + "right" + } else { + "left" + }, + |value| value.trim(), + ); + if !matches!(alignment, "left" | "center" | "right") { + bail!("column alignment must be left, center, or right"); + } columns.push(ViewColumn { field, + category, heading, width, aggregate, + number_label, + decimals, + decimal_separator: decimal_separator.into(), + thousands_separator: thousands_separator.into(), + negative_style: negative_style.into(), + percent_total, + alignment: alignment.into(), }); } if columns.is_empty() { @@ -1487,6 +2145,24 @@ fn parse_columns_spec(spec: &str) -> Result> { Ok(columns) } +fn parse_bound_field(source: &str) -> Option<(String, Option)> { + let (field, category) = source.trim().split_once('[')?; + let category = category.strip_suffix(']')?.trim(); + if category.is_empty() || !matches!(field.to_lowercase().as_str(), "numeric" | "date" | "value") + { + return None; + } + Some(( + if field.eq_ignore_ascii_case("value") { + "numeric" + } else { + field + } + .to_lowercase(), + Some(category.into()), + )) +} + fn parse_sections_spec(spec: &str) -> Result> { let mut sections = vec![]; for raw in spec.split(';').map(str::trim).filter(|s| !s.is_empty()) { @@ -1518,6 +2194,8 @@ fn default_heading(field: &str) -> &str { "priority" => "P", "note" => "Note", "value" => "Value", + "numeric" => "Value", + "date" => "Date", "done" => "Done", "alarm" => "Alarm", "recurrence" => "Repeats", @@ -1660,8 +2338,7 @@ mod tests { let important = db .save_category(None, "Important", None, "standard", "", false) .unwrap(); - db.save_primary_rule(important, "text~urgent", "assign:") - .unwrap(); + db.save_primary_rule(important, "text~urgent", "").unwrap(); let item = db.add_item("Urgent customer request").unwrap(); assert!(db.item_category_ids(item).unwrap().contains(&important)); let promoter = db @@ -1678,7 +2355,7 @@ mod tests { .unwrap(); assert_eq!(db.items(&all, "").unwrap()[0].priority, 1); assert!( - db.save_primary_rule(important, "priority nope 2", "assign:") + db.save_primary_rule(important, "priority nope 2", "") .is_err() ); assert_eq!( @@ -1807,6 +2484,7 @@ mod tests { afternoon_time: "14:00".into(), evening_time: "19:15".into(), note_tab_width: 8, + report_headers: true, }) .unwrap(); } @@ -1911,4 +2589,354 @@ mod tests { assert_eq!(items[0].recurrence, "every 2 weeks"); assert!(items[0].categories.iter().any(|c| c.name == "Projects")); } + + #[test] + fn numeric_categories_hold_independent_values_and_drive_filters_and_sorting() { + let directory = tempdir().unwrap(); + let mut db = Database::open(&directory.path().join("numbers.agnd")).unwrap(); + let hours = db + .save_category(None, "Hours", None, "numeric", "", false) + .unwrap(); + let rate = db + .save_category(None, "Rate", None, "numeric", "", false) + .unwrap(); + let first = db.add_item("First job").unwrap(); + let second = db.add_item("Second job").unwrap(); + let missing = db.add_item("No estimate").unwrap(); + db.set_numeric_value(first, hours, Some(8.25)).unwrap(); + db.set_numeric_value(first, rate, Some(125.0)).unwrap(); + db.set_numeric_value(second, hours, Some(2.0)).unwrap(); + let view_id = db + .save_view_design( + None, + "Billable", + "list", + "", + "numeric[Hours]", + true, + "numeric:Hours>=2 and numeric:Rate inside 100..150", + "item:40:Job,numeric[Hours]:12:Hours:sum::2:dot:comma:minus:percent:right,numeric[Rate]:14:Rate:none:$:2:comma:dot:parentheses:value:right", + "", + ) + .unwrap(); + let view = db + .views() + .unwrap() + .into_iter() + .find(|view| view.id == view_id) + .unwrap(); + let items = db.items(&view, "").unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].numeric_value_for("Hours"), Some(8.25)); + assert_eq!(items[0].numeric_value_for("Rate"), Some(125.0)); + assert!(view.columns[1].percent_total); + assert_eq!(view.columns[1].aggregate_value(items.iter()), "Total 8.25"); + assert_eq!(view.columns[2].format_number(-1250.0), "($1.250,00)"); + let mut european = view.columns[2].clone(); + european.number_label.clear(); + european.decimal_separator = ",".into(); + european.thousands_separator = "dot".into(); + european.negative_style = "minus".into(); + assert_eq!(european.format_number(1234.5), "1.234,50"); + let all = db + .views() + .unwrap() + .into_iter() + .find(|view| view.name == "All Items") + .unwrap(); + let all_items = db.items(&all, "").unwrap(); + let second_item = all_items.iter().find(|item| item.id == second).unwrap(); + assert_eq!(view.columns[2].numeric_value(second_item), None); + let mut count_column = view.columns[1].clone(); + count_column.aggregate = Aggregate::Count; + assert_eq!(count_column.aggregate_value(all_items.iter()), "Count 2"); + let sorted_id = db + .save_view_design( + None, + "Sorted hours", + "list", + "", + "numeric[Hours]", + true, + "", + "item:40:Job,numeric[Hours]:12:Hours", + "", + ) + .unwrap(); + let sorted = db + .views() + .unwrap() + .into_iter() + .find(|view| view.id == sorted_id) + .unwrap(); + assert_eq!( + db.items(&sorted, "") + .unwrap() + .iter() + .map(|item| item.id) + .collect::>(), + vec![second, first, missing] + ); + } + + #[test] + fn conditional_assignments_withdraw_but_explicit_assignments_survive() { + let directory = tempdir().unwrap(); + let mut db = Database::open(&directory.path().join("conditions.agnd")).unwrap(); + let mut settings = db.document_settings().unwrap(); + settings.automatic_filing = false; + db.save_document_settings(&settings).unwrap(); + let urgent = db + .save_category(None, "Urgent", None, "standard", "", false) + .unwrap(); + db.save_primary_rule(urgent, "text~urgent", "").unwrap(); + let item = db.add_item("Urgent request").unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&urgent)); + db.toggle_assignment(item, urgent).unwrap(); + assert!(!db.item_category_ids(item).unwrap().contains(&urgent)); + db.toggle_assignment(item, urgent).unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&urgent)); + db.update_item( + item, + &ItemChanges { + text: Some("Routine request".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(!db.item_category_ids(item).unwrap().contains(&urgent)); + + db.update_item( + item, + &ItemChanges { + text: Some("Urgent once more".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&urgent)); + db.save_primary_rule(urgent, "", "").unwrap(); + assert!(!db.item_category_ids(item).unwrap().contains(&urgent)); + db.save_primary_rule(urgent, "text~urgent", "").unwrap(); + + db.toggle_assignment(item, urgent).unwrap(); + db.update_item( + item, + &ItemChanges { + text: Some("Urgent again".into()), + ..Default::default() + }, + ) + .unwrap(); + db.update_item( + item, + &ItemChanges { + text: Some("Routine again".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&urgent)); + } + + #[test] + fn actions_are_explicit_one_shot_mutations_and_execute_reapplies_them() { + let directory = tempdir().unwrap(); + let mut db = Database::open(&directory.path().join("actions.agnd")).unwrap(); + let source = db + .save_category(None, "Billable", None, "standard", "", false) + .unwrap(); + let hours = db + .save_category(None, "Hours", None, "numeric", "", false) + .unwrap(); + db.save_primary_rule(source, "", "numeric:Hours=7.5") + .unwrap(); + let item = db.add_item("Consulting").unwrap(); + db.toggle_assignment(item, source).unwrap(); + let all = db + .views() + .unwrap() + .into_iter() + .find(|view| view.name == "All Items") + .unwrap(); + assert_eq!( + db.items(&all, "").unwrap()[0].numeric_value_for("Hours"), + Some(7.5) + ); + + let existing_source = db + .save_category(None, "Already Filed", None, "standard", "", false) + .unwrap(); + let existing = db.add_item("Existing source member").unwrap(); + db.toggle_assignment(existing, existing_source).unwrap(); + db.save_primary_rule(existing_source, "", "numeric:Hours=9") + .unwrap(); + db.apply_rules_to_all().unwrap(); + let existing_item = db + .items(&all, "") + .unwrap() + .into_iter() + .find(|item| item.id == existing) + .unwrap(); + assert_eq!(existing_item.numeric_value_for("Hours"), None); + db.execute_rules(&[existing]).unwrap(); + let existing_item = db + .items(&all, "") + .unwrap() + .into_iter() + .find(|item| item.id == existing) + .unwrap(); + assert_eq!(existing_item.numeric_value_for("Hours"), Some(9.0)); + db.set_numeric_value(item, hours, Some(3.0)).unwrap(); + assert_eq!( + db.items(&all, "").unwrap()[0].numeric_value_for("Hours"), + Some(3.0) + ); + assert_eq!(db.execute_rules(&[item]).unwrap().conflicts, 0); + assert_eq!( + db.items(&all, "").unwrap()[0].numeric_value_for("Hours"), + Some(7.5) + ); + } + + #[test] + fn reports_include_numeric_footers_percentages_formatting_and_metadata() { + let directory = tempdir().unwrap(); + let mut db = Database::open(&directory.path().join("calculation-report.agnd")).unwrap(); + let cost = db + .save_category(None, "Cost", None, "numeric", "", false) + .unwrap(); + for (text, value) in [("First", 25.0), ("Second", 75.0)] { + let item = db.add_item(text).unwrap(); + db.set_numeric_value(item, cost, Some(value)).unwrap(); + } + db.save_view_design( + None, + "Costs", + "list", + "", + "manual", + true, + "", + "item:50:Item,numeric[Cost]:20:Cost:sum:$:2:.:comma:parentheses:percent:right", + "", + ) + .unwrap(); + let markdown_path = directory.path().join("costs.md"); + db.export("markdown", &markdown_path, Some("Costs")) + .unwrap(); + let markdown = fs::read_to_string(markdown_path).unwrap(); + assert!(markdown.contains("Cost %")); + assert!(markdown.contains("25.00%")); + assert!(markdown.contains("Total $100.00")); + assert!(markdown.contains("Generated:")); + let html_path = directory.path().join("costs.html"); + db.export("html", &html_path, Some("Costs")).unwrap(); + let html = fs::read_to_string(html_path).unwrap(); + assert!(html.contains("display:table-header-group")); + assert!(html.contains("Total $100.00")); + + let mut settings = db.document_settings().unwrap(); + settings.report_headers = false; + db.save_document_settings(&settings).unwrap(); + let plain_path = directory.path().join("plain.html"); + db.export("html", &plain_path, Some("Costs")).unwrap(); + let plain = fs::read_to_string(plain_path).unwrap(); + assert!(!plain.contains("
")); + assert!(!plain.contains("