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

@@ -54,7 +54,8 @@ It provides:
and Customer. and Customer.
- Calls, meetings, and follow-ups under an Activities hierarchy. - Calls, meetings, and follow-ups under an Activities hierarchy.
- Open, waiting, and resolved issue states. - 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. - A sectioned status report divided into urgent and routine material.
- Filing rules that collect priority-one work into the status report and detect - Filing rules that collect priority-one work into the status report and detect
follow-up wording. 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 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 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: It provides:
@@ -119,7 +121,7 @@ It provides:
- Course categories for Breakfast, Main Dishes, Desserts, and Drinks. - Course categories for Breakfast, Main Dishes, Desserts, and Drinks.
- Cuisine, dietary, ingredient, Quick, and Favorites classifications. - 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 - Recipe Box, Favorites, Main Dishes, Quick Meals, Vegetarian, and sectioned By
Course views. Course views.
- Five small original recipes covering different courses and dietary tags. - 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` ## `rides`
Bicycle Log demonstrates numeric reporting and several overlapping category 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: It provides:
@@ -149,8 +152,9 @@ It provides:
- Rules that recognize ride and maintenance wording. - Rules that recognize ride and maintenance wording.
- `logride` and `servicereminder` capture macros. - `logride` and `servicereminder` capture macros.
Use `F6` on a newly captured ride to record its numeric distance and date, then Assign Distance with `F3`, select the Distance cell with Left/Right, and press
assign bicycle, ride type, route, and weather with `F3`. `F2` to enter kilometres. Use `F6` for the ride date, then assign bicycle, ride
type, route, and weather with `F3`.
## `people` ## `people`
@@ -166,7 +170,7 @@ It provides:
hiring activity. hiring activity.
- People Directory, Upcoming Reviews, Goals, Development, Candidates, and - People Directory, Upcoming Reviews, Goals, Development, Candidates, and
Achievements views. 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. - Filing rules for `Person:`, `Review:`, and `Goal:` capture conventions.
- `addperson` and `addgoal` prompt-driven macros. - `addperson` and `addgoal` prompt-driven macros.

View File

@@ -70,7 +70,8 @@ Research URLs:
- [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL, - [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL,
transactions, integrity-friendly soft deletion, and automatic checkpoints. 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 recurring schedule, creation/update timestamps, manual order, and prerequisite
links. Completion advances recurring work and carries alarms and filing forward. links. Completion advances recurring work and carries alarms and filing forward.
- [x] Categories with hierarchy, inheritance in queries, kinds (standard, date, - [x] Categories with hierarchy, inheritance in queries, kinds (standard, date,
@@ -136,28 +137,21 @@ Research URLs:
## Supported with a modern interpretation ## Supported with a modern interpretation
- [x] Numeric calculations currently use one optional `numeric_value` per item and - [x] Numeric calculations use independent itemnumeric-category values and
expose five named aggregate modes: `sum`, `avg`, `count`, `min`, and `max` category-bound view columns. Count, Total, Average, Minimum, and Maximum follow
(`model/enums.rs` `Aggregate`, `ui.rs` `aggregate_value`). Sum, average, minimum, populated-cell semantics per section; formatting and `% of total` are column
and maximum operate on the populated values in a section. Count currently counts properties. The retired `items.numeric_value` column remains only as a migration
every item in the section, whereas Lotus Count counted populated entries in the bridge for version-4 documents and older API callers.
numeric column. The names therefore align with Lotus, but the data model, - [x] Conditions and actions follow separate Lotus lifecycles. Conditions maintain
category scoping, Count semantics, formatting, and presentation do not yet have reversible conditional assignments to their own categories; actions fire once
full parity. on source-category entry and make explicit persistent changes. Category-scoped
- [x] Conditions / actions: each `CategoryRule` pairs a parsed condition date/numeric ranges and actions, removal, Done/export/discard Special actions,
expression (`filter.rs` `Predicate`: text/note containment, category membership, scoped Utilities Execute, and inspectable non-convergence state are supported.
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] Printing: view-aware Markdown and styled HTML reports are the portable - [x] Printing: view-aware Markdown and styled HTML reports are the portable
print/preview foundation, alongside CSV and JSON data exports. Markdown and HTML print/preview foundation, alongside CSV and JSON data exports. Markdown and HTML
currently omit aggregate rows, percentage-of-total columns, numeric formatting, include live-view aggregate rows, percentage-of-total columns, portable numeric
and other report-layout fidelity listed below. formatting, relative widths/alignment, print CSS, metadata, and optional headers
and footers.
- [x] Backups: SQLite durability is supplemented by explicit/manual and - [x] Backups: SQLite durability is supplemented by explicit/manual and
backup-on-open `.agnd.bak` snapshots after a full WAL checkpoint. Ordinary backup-on-open `.agnd.bak` snapshots after a full WAL checkpoint. Ordinary
filesystem backup tools also work on `.agnd` files. filesystem backup tools also work on `.agnd` files.
@@ -174,60 +168,60 @@ file-format compatibility are not parity goals.
### Numeric data and calculations ### Numeric data and calculations
- [ ] Store a separate value for every itemnumeric-category assignment instead of - [x] Store a separate value for every itemnumeric-category assignment rather
the current single `items.numeric_value` scalar. Preserve explicit, automatic, than relying on the legacy `items.numeric_value` scalar. Preserve explicit,
and conditional assignment provenance while doing so. automatic, and conditional assignment provenance while doing so.
- [ ] Bind numeric view columns to a specific numeric category so several values, - [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, such as Hours, Rate, Cost, and Budget, can coexist on one item and be displayed,
filtered, sorted, edited, and aggregated independently. 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. 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, populated numeric entries rather than rows, and calculate Total, Average,
Minimum, and Maximum from that column's values only. 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 places, thousands separator, negative-number style, and full-precision storage
independent of rounded display. 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. 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 highlighted numeric, date, text, category, or item value so Agenda-style macro
arithmetic can operate on document data rather than only on item text. arithmetic can operate on document data rather than only on item text.
### Conditions and actions ### 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, conditional assignment to its own category; an action creates an explicit,
persistent assignment or value change after an item enters its source category. 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. 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. 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. 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. including assignment removal and category-specific date and numeric values.
- [ ] Complete Special actions: designate as done, export to a selected structured - [x] Complete Special actions: designate as done, export to a selected structured
destination, and discard. `Done` exists today; export and discard do not. destination, and discard.
- [ ] Add a user-facing re-evaluation command equivalent to Utilities `Execute`, - [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 scoped to the current item, marked items, section, view, or whole document. This
means applying rules to existing data, not executing an external process. 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. of only returning a non-convergence error.
### Markdown, HTML, and modern printing ### 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. 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. 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. 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. 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. 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 view name, active filters, and generation time, without changing data-only CSV
and JSON exports into presentation formats. and JSON exports into presentation formats.
@@ -244,11 +238,6 @@ file-format compatibility are not parity goals.
## Near-term plan ## Near-term plan
1. Implement per-numeric-category values and category-bound numeric columns. 1. Add desktop alarm integration and in-application snooze controls.
2. Correct conditional-assignment lifecycle and separate conditions from explicit 2. Add encrypted documents and secure unlock handling.
actions. 3. Add property-based tests for complex hierarchy, rule, and numeric combinations.
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.

View File

@@ -14,8 +14,9 @@ database with the `.agnd` extension.
## Features ## Features
- Free-form items with multiline notes, priority, due dates, done dates, alarms, - Free-form items with multiline notes, priority, due dates, done dates, alarms,
numeric values, recurring schedules, and prerequisites. Completing a recurring independent date and numeric category values, recurring schedules, and
item creates its next dated occurrence and carries its alarm and filing forward. 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 - Hierarchical categories with inherited membership and explicit many-to-many
assignment and negative exclusions: an item exists once but can appear in many assignment and negative exclusions: an item exists once but can appear in many
contexts. contexts.
@@ -23,14 +24,15 @@ database with the `.agnd` extension.
`today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates. `today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates.
- Live views for all items, tasks, upcoming work, completed work, and a datebook. - Live views for all items, tasks, upcoming work, completed work, and a datebook.
- Full-text search plus composable Boolean filters over text, notes, categories, - Full-text search plus composable Boolean filters over text, notes, categories,
dates, done state, priority, numeric value, alarms, recurrence, and relative due dates, done state, priority, category-scoped numeric values, alarms, recurrence,
windows. and relative due windows.
- Agenda-style View Manager and Category Manager screens with editable live - Agenda-style View Manager and Category Manager screens with editable live
view definitions, hierarchy, category kinds, match phrases, and exclusivity. view definitions, hierarchy, category kinds, match phrases, and exclusivity.
- Persistent per-view columns, custom headings and widths, numeric aggregates, - Persistent per-view columns, custom headings and widths, numeric aggregates,
filtered multi-section layouts, collapsed sections, and view reordering. filtered multi-section layouts, collapsed sections, and view reordering.
- User-authored category rules with a live match preview and deterministic actions - Lotus-style live conditions and persistent actions with match previews,
for assignments, exclusions, priority, value, dates, and completion. 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 - Marking and bulk completion, soft deletion with Trash recovery, sorting, and
dependency visibility. dependency visibility.
- View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar - View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar
@@ -130,13 +132,14 @@ or build failure stops the gate.
| Key | Action | | 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 | | `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 | | `F3` or `c` | Assign categories |
| `F4` or `d` | Toggle done | | `F4` or `d` | Toggle done |
| `F5` | Edit the item's note | | `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 | | `F7` or `Space` | Mark for bulk operations |
| `F8` or `v` | View Manager | | `F8` or `v` | View Manager |
| `F9` | Category 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 - carriage-return and item markers
- autosave checkpoint interval and destructive-action confirmations - autosave checkpoint interval and destructive-action confirmations
- ISO, US, European, or long date display and 12/24-hour time - 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 Greenscreen, Nord, and Catppuccin Mocha use dedicated selected-row
foreground/background pairs with at least 7:1 relative-luminance contrast. 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 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 named times, and note-editor tab width. `this week` and `next week` honor the
chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured 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 Enumerated preferences and document settings use choice popovers, so values such
as themes, yes/no switches, policies, date formats, and week boundaries cannot be 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. `Ctrl-Up`/`Ctrl-Down` to reorder it, or `Delete` to remove it.
Boolean filters use `and`, `or`, `not`, and parentheses. Available predicates 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`, `done`, `open`, `dated`, `undated`, `due<=7d`, `overdue`, `has-note`, `alarm`,
`recurring`, and `nonrecurring`. `recurring`, and `nonrecurring`.
For example: For example:
@@ -267,15 +274,32 @@ For example:
category=Work and open and (priority<=2 or due<=3d) category=Work and open and (priority<=2 or due<=3d)
``` ```
Columns use `field:width:heading:aggregate`, separated by commas. Fields are Columns use `field:width:heading:aggregate`, separated by commas. Ordinary fields
`item`, `categories`, `when`, `priority`, `note`, `value`, `done`, `alarm`, are `item`, `categories`, `when`, `priority`, `note`, `done`, `alarm`,
`recurrence`, `created`, and `updated`; aggregates are `none`, `sum`, `avg`, `recurrence`, `created`, and `updated`. Category values use `numeric[Category]`
`count`, `min`, or `max`. 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 ```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 Sections use `heading|filter`, separated by semicolons. Add `|collapsed` to make
a section initially collapsed. a section initially collapsed.
@@ -286,14 +310,22 @@ Urgent|priority=1;Upcoming|due<=7d;Someday|undated|collapsed
## Automatic category rules ## Automatic category rules
Open the Category Manager with `F9`, then create or edit a category. In addition 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 to simple match phrases, each category can have a Boolean condition and an
action. The form previews how many existing items match before saving. 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`, An action fires once when an item enters the category and creates a persistent
`value:125.50`, `when:tomorrow at 3pm`, `alarm:tomorrow at 2pm`, change. Actions use `action:value`: `assign:Category`, `exclude:Category`,
`repeat:weekdays`, or `done:true`. `assign:` with no value assigns the category `remove:Category`, `priority:1`, `numeric:Hours=7.5`,
being edited. Rules are reapplied to existing information and whenever an item `date:Review Date=tomorrow`, `when:tomorrow at 3pm`,
changes. `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 ## 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 `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 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 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`. assignments. iCalendar import and export maps these schedules to `RRULE`.
## Design notes ## Design notes

View File

@@ -11,13 +11,14 @@ use ratatui::layout::Rect;
use crate::{ use crate::{
db::Database, db::Database,
filter,
macro_lang::{ macro_lang::{
MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name, MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name,
parse_key_binding, parse_key_binding,
}, },
model::{ model::{
Category, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, Category, CategoryKind, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges,
TrashPolicy, ViewDef, ViewKind, WeekStart, MacroDef, TrashPolicy, ViewDef, ViewKind, WeekStart,
}, },
preferences::AppPreferences, preferences::AppPreferences,
}; };
@@ -41,6 +42,11 @@ pub enum InputKind {
MacroEditor(Option<i64>), MacroEditor(Option<i64>),
MacroBinding(i64), MacroBinding(i64),
MacroPrompt(String), MacroPrompt(String),
CategoryValue {
item_id: i64,
category_id: i64,
kind: CategoryKind,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -56,7 +62,7 @@ pub struct InputState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PropsState { pub struct PropsState {
pub item_id: i64, pub item_id: i64,
pub values: [String; 5], pub values: [String; 4],
pub field: usize, pub field: usize,
} }
@@ -104,6 +110,10 @@ pub enum Mode {
Views, Views,
CategoryManager, CategoryManager,
Menu, Menu,
Execute {
selected: usize,
},
RuleConflicts,
Confirm { Confirm {
prompt: String, prompt: String,
action: ConfirmAction, action: ConfirmAction,
@@ -151,6 +161,7 @@ pub struct App {
pub(crate) view_index: usize, pub(crate) view_index: usize,
pub(crate) items: Vec<Item>, pub(crate) items: Vec<Item>,
pub(crate) selected: usize, pub(crate) selected: usize,
pub(crate) selected_column: usize,
pub(crate) scroll: usize, pub(crate) scroll: usize,
pub(crate) search: String, pub(crate) search: String,
pub(crate) marked: HashSet<i64>, pub(crate) marked: HashSet<i64>,
@@ -161,6 +172,7 @@ pub struct App {
pub(crate) status: String, pub(crate) status: String,
pub(crate) should_quit: bool, pub(crate) should_quit: bool,
pub(crate) item_rows: Vec<(Rect, i64)>, pub(crate) item_rows: Vec<(Rect, i64)>,
pub(crate) item_columns: Vec<(Rect, usize)>,
pub(crate) command_regions: Vec<Rect>, pub(crate) command_regions: Vec<Rect>,
pub(crate) view_regions: Vec<Rect>, pub(crate) view_regions: Vec<Rect>,
pub(crate) choice_regions: Vec<Rect>, pub(crate) choice_regions: Vec<Rect>,
@@ -194,11 +206,12 @@ impl App {
} }
fn build( fn build(
db: Database, mut db: Database,
path: PathBuf, path: PathBuf,
preferences: AppPreferences, preferences: AppPreferences,
preferences_path: PathBuf, preferences_path: PathBuf,
) -> Result<Self> { ) -> Result<Self> {
db.apply_rules_to_all()?;
let views = db.views()?; let views = db.views()?;
let categories = db.categories()?; let categories = db.categories()?;
let document_settings = db.document_settings()?; let document_settings = db.document_settings()?;
@@ -214,6 +227,7 @@ impl App {
view_index: 0, view_index: 0,
items: vec![], items: vec![],
selected: 0, selected: 0,
selected_column: 0,
scroll: 0, scroll: 0,
search: String::new(), search: String::new(),
marked: HashSet::new(), marked: HashSet::new(),
@@ -224,6 +238,7 @@ impl App {
status: "Ready — capture first, organize later".into(), status: "Ready — capture first, organize later".into(),
should_quit: false, should_quit: false,
item_rows: vec![], item_rows: vec![],
item_columns: vec![],
command_regions: vec![], command_regions: vec![],
view_regions: vec![], view_regions: vec![],
choice_regions: vec![], choice_regions: vec![],
@@ -268,6 +283,9 @@ impl App {
self.categories = self.db.categories()?; self.categories = self.db.categories()?;
self.view_index = self.view_index.min(self.views.len().saturating_sub(1)); 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.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 self.selected = keep
.and_then(|id| self.items.iter().position(|i| i.id == id)) .and_then(|id| self.items.iter().position(|i| i.id == id))
.unwrap_or(self.selected.min(self.items.len().saturating_sub(1))); .unwrap_or(self.selected.min(self.items.len().saturating_sub(1)));
@@ -396,6 +414,11 @@ impl App {
Mode::Views => self.handle_views(key), Mode::Views => self.handle_views(key),
Mode::CategoryManager => self.handle_category_manager(key), Mode::CategoryManager => self.handle_category_manager(key),
Mode::Menu => self.handle_menu(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::Confirm { .. } => self.handle_confirm(key),
Mode::Choice(_) => self.handle_choice(key), Mode::Choice(_) => self.handle_choice(key),
Mode::MacroManager => self.handle_macro_manager(key), Mode::MacroManager => self.handle_macro_manager(key),
@@ -414,6 +437,8 @@ impl App {
let today = Local::now().date_naive(); let today = Local::now().date_naive();
if today != self.current_day { if today != self.current_day {
self.current_day = today; self.current_day = today;
self.db.apply_rules_to_all()?;
self.refresh()?;
if self.document_settings.trash_policy == TrashPolicy::EndOfDay { if self.document_settings.trash_policy == TrashPolicy::EndOfDay {
let count = self.db.empty_trash()?; let count = self.db.empty_trash()?;
if count > 0 { if count > 0 {
@@ -428,6 +453,20 @@ impl App {
fn macro_context(&self) -> MacroContext { fn macro_context(&self) -> MacroContext {
let now = Local::now(); let now = Local::now();
let highlighted = self.selected_item(); 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 { MacroContext {
date: now.format("%Y-%m-%d").to_string(), date: now.format("%Y-%m-%d").to_string(),
time: now.format("%H:%M:%S").to_string(), time: now.format("%H:%M:%S").to_string(),
@@ -450,10 +489,8 @@ impl App {
_ => "BOX", _ => "BOX",
} }
.into(), .into(),
highlight_type: highlighted.map_or("NONE", |_| "ITEM").into(), highlight_type: highlight_type.into(),
highlight_value: highlighted highlight_value,
.map(|item| item.text.clone())
.unwrap_or_default(),
mark_count: self.marked.len(), mark_count: self.marked.len(),
marked_in_view: self marked_in_view: self
.items .items
@@ -493,8 +530,7 @@ impl App {
fn current_prompt(&self) -> String { fn current_prompt(&self) -> String {
match &self.mode { match &self.mode {
Mode::Properties(properties) => { Mode::Properties(properties) => {
["Priority", "When", "Alarm", "Numeric value", "Recurrence"][properties.field] ["Priority", "When", "Alarm", "Recurrence"][properties.field].into()
.into()
} }
Mode::Form(form) => form_prompt(&form.kind, form.field).into(), Mode::Form(form) => form_prompt(&form.kind, form.field).into(),
Mode::Input(input) => input.title.clone(), Mode::Input(input) => input.title.clone(),
@@ -869,6 +905,11 @@ impl App {
KeyCode::Char('q') => self.should_quit = true, KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => 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::PageUp => self.move_selection(-10),
KeyCode::PageDown => self.move_selection(10), KeyCode::PageDown => self.move_selection(10),
KeyCode::Home => self.selected = 0, KeyCode::Home => self.selected = 0,
@@ -876,7 +917,7 @@ impl App {
KeyCode::Insert | KeyCode::Char('n') => { KeyCode::Insert | KeyCode::Char('n') => {
self.open_input(InputKind::NewItem, "New item", String::new(), false) 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') => { KeyCode::F(3) | KeyCode::Char('c') => {
if self.selected_item().is_some() { if self.selected_item().is_some() {
self.category_selected = 0; self.category_selected = 0;
@@ -928,6 +969,7 @@ impl App {
let len = self.views.len() as isize; let len = self.views.len() as isize;
self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize; self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize;
self.selected = 0; self.selected = 0;
self.selected_column = 0;
self.scroll = 0; self.scroll = 0;
self.search.clear(); self.search.clear();
self.refresh()?; self.refresh()?;
@@ -944,14 +986,49 @@ impl App {
cursor, cursor,
}); });
} }
fn edit_selected(&mut self) { fn edit_highlighted(&mut self) {
if let Some(i) = self.selected_item() { 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( self.open_input(
InputKind::EditItem(i.id), InputKind::CategoryValue {
"Edit item", item_id: item.id,
i.text.clone(), category_id: category.id,
kind: category.kind,
},
&format!("{} value", category.name),
value,
false, false,
); );
} else {
self.open_input(InputKind::EditItem(item.id), "Edit item", item.text, false);
} }
} }
fn edit_note(&mut self) { fn edit_note(&mut self) {
@@ -972,7 +1049,6 @@ impl App {
i.priority.to_string(), i.priority.to_string(),
i.when_at.clone().unwrap_or_default(), i.when_at.clone().unwrap_or_default(),
i.alarm_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(), i.recurrence.clone(),
], ],
field: 0, field: 0,
@@ -1091,7 +1167,7 @@ impl App {
match input.kind { match input.kind {
InputKind::NewItem => { InputKind::NewItem => {
self.db.add_item(&input.value)?; 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) => { InputKind::EditItem(id) => {
self.db.update_item( self.db.update_item(
@@ -1170,6 +1246,33 @@ impl App {
self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal);
return Ok(()); 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::<f64>()
.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.mode = Mode::Normal;
self.refresh()?; self.refresh()?;
@@ -1185,8 +1288,8 @@ impl App {
self.mode = Mode::Normal; self.mode = Mode::Normal;
return Ok(()); return Ok(());
} }
KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5, KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 4,
KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5, KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 3) % 4,
KeyCode::Backspace => { KeyCode::Backspace => {
props.values[props.field].pop(); props.values[props.field].pop();
} }
@@ -1200,18 +1303,9 @@ impl App {
} }
let when_at = normalize_date(&self.db, &props.values[1])?; let when_at = normalize_date(&self.db, &props.values[1])?;
let alarm_at = normalize_date(&self.db, &props.values[2])?; let alarm_at = normalize_date(&self.db, &props.values[2])?;
let numeric_value = if props.values[3].trim().is_empty() { Ok((priority, when_at, alarm_at))
None
} else {
Some(
props.values[3]
.parse::<f64>()
.context("numeric value must be a number")?,
)
};
Ok((priority, when_at, alarm_at, numeric_value))
})(); })();
let (priority, when_at, alarm_at, numeric_value) = match parsed { let (priority, when_at, alarm_at) = match parsed {
Ok(values) => values, Ok(values) => values,
Err(error) => { Err(error) => {
self.status = format!("Cannot save properties: {error:#}"); self.status = format!("Cannot save properties: {error:#}");
@@ -1225,8 +1319,7 @@ impl App {
priority: Some(priority), priority: Some(priority),
when_at: Some(when_at), when_at: Some(when_at),
alarm_at: Some(alarm_at), alarm_at: Some(alarm_at),
numeric_value: Some(numeric_value), recurrence: Some(props.values[3].clone()),
recurrence: Some(props.values[4].clone()),
..Default::default() ..Default::default()
}, },
)?; )?;
@@ -1373,6 +1466,8 @@ impl App {
KeyCode::Char('p') => self.open_preferences_form(), KeyCode::Char('p') => self.open_preferences_form(),
KeyCode::Char('d') => self.open_document_settings_form(), KeyCode::Char('d') => self.open_document_settings_form(),
KeyCode::Char('x') => self.mode = Mode::MacroManager, 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') => { KeyCode::Char('b') => {
let path = self.db.backup_now()?; let path = self.db.backup_now()?;
self.status = format!("Backup written to {}", path.display()); self.status = format!("Backup written to {}", path.display());
@@ -1393,6 +1488,60 @@ impl App {
Ok(()) 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(&section.filter_expr, item).unwrap_or(false)
})
});
self.items
.iter()
.filter(|item| {
section.is_some_and(|section| {
filter::matches(&section.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<()> { fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Confirm { action, .. } = self.mode.clone() else { let Mode::Confirm { action, .. } = self.mode.clone() else {
return Ok(()); return Ok(());
@@ -1483,6 +1632,7 @@ impl App {
settings.afternoon_time.clone(), settings.afternoon_time.clone(),
settings.evening_time.clone(), settings.evening_time.clone(),
settings.note_tab_width.to_string(), settings.note_tab_width.to_string(),
yes_no(settings.report_headers),
], ],
field: 0, field: 0,
}); });
@@ -1701,6 +1851,7 @@ impl App {
note_tab_width: form.values[11] note_tab_width: form.values[11]
.parse() .parse()
.context("note tab width must be a number")?, .context("note tab width must be a number")?,
report_headers: parse_yes_no(&form.values[12])?,
}; };
self.db.save_document_settings(&settings)?; self.db.save_document_settings(&settings)?;
self.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) { if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
self.selected = pos; self.selected = pos;
} }
if let Some((_, column)) = self
.item_columns
.iter()
.find(|(region, _)| inside(*region, x, y))
{
self.selected_column = *column;
}
return Ok(()); return Ok(());
} }
if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) { 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-priority".into(),
"not-a-date".into(), "not-a-date".into(),
String::new(), String::new(),
"not-a-number".into(),
String::new(), String::new(),
], ],
field: 0, field: 0,
@@ -2106,4 +2263,55 @@ mod tests {
assert!(app.items[0].when_at.is_none()); assert!(app.items[0].when_at.is_none());
assert!(app.items[0].numeric_value.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));
}
} }

View File

@@ -56,7 +56,7 @@ pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option<Vec<ChoiceOp
_ => return None, _ => return None,
}, },
FormKind::DocumentSettings => match field { FormKind::DocumentSettings => match field {
1 | 4 => YES_NO_CHOICES, 1 | 4 | 12 => YES_NO_CHOICES,
2 => &[ 2 => &[
("On demand", "on-demand"), ("On demand", "on-demand"),
("On close", "on-close"), ("On close", "on-close"),
@@ -135,6 +135,7 @@ pub(super) fn form_prompt(kind: &FormKind, field: usize) -> &'static str {
"Afternoon time", "Afternoon time",
"Evening time", "Evening time",
"Note tab width", "Note tab width",
"Report headers and footers",
], ],
}; };
prompts.get(field).copied().unwrap_or("") prompts.get(field).copied().unwrap_or("")

1246
src/db.rs

File diff suppressed because it is too large Load Diff

View File

@@ -97,29 +97,59 @@ fn recurrence_to_rrule(value: &str) -> Option<String> {
}) })
} }
pub(super) fn render_markdown(view: &ViewDef, items: &[Item]) -> String { pub(super) fn render_markdown(
let mut output = format!("# Rogue Agenda — {}\n\n", view.name); view: &ViewDef,
items: &[Item],
description: &str,
show_headers: bool,
) -> String {
let generated = Utc::now().to_rfc3339();
let mut output = String::new();
if show_headers {
output.push_str(&format!("# Rogue Agenda — {}\n\n", view.name));
if !description.is_empty() {
output.push_str(&format!("{}\n\n", markdown(description)));
}
output.push_str(&format!(
"View: **{}** \nFilters: `{}` \nGenerated: {}\n\n",
markdown(&view.name),
markdown(&filter_summary(view)),
generated
));
}
for (heading, group) in report_groups(view, items) { for (heading, group) in report_groups(view, items) {
if !heading.is_empty() { if !heading.is_empty() {
output.push_str(&format!("## {}\n\n", markdown(&heading))); output.push_str(&format!("## {}\n\n", markdown(&heading)));
} }
output.push('|'); output.push('|');
for column in &view.columns { for column in report_columns(view) {
output.push_str(&format!(" {} |", markdown(&column.heading))); output.push_str(&format!(" {} |", markdown(&column.heading())));
} }
output.push('\n'); output.push('\n');
output.push('|'); output.push('|');
for _ in &view.columns { for column in report_columns(view) {
output.push_str(" --- |"); output.push_str(match column.alignment() {
"right" => " ---: |",
"center" => " :---: |",
_ => " :--- |",
});
} }
output.push('\n'); output.push('\n');
for item in group { for item in &group {
output.push('|'); output.push('|');
for column in &view.columns { for column in report_columns(view) {
output.push_str(&format!( output.push_str(&format!(" {} |", markdown(&column.value(item, &group))));
" {} |", }
markdown(&report_value(&column.field, item)) output.push('\n');
)); }
if view
.columns
.iter()
.any(|column| column.aggregate != crate::model::Aggregate::None)
{
output.push('|');
for column in report_columns(view) {
output.push_str(&format!(" {} |", markdown(&column.aggregate(&group))));
} }
output.push('\n'); output.push('\n');
} }
@@ -128,37 +158,91 @@ pub(super) fn render_markdown(view: &ViewDef, items: &[Item]) -> String {
output output
} }
pub(super) fn render_html(view: &ViewDef, items: &[Item]) -> String { pub(super) fn render_html(
view: &ViewDef,
items: &[Item],
description: &str,
show_headers: bool,
) -> String {
let generated = Utc::now().to_rfc3339();
let mut output = format!( let mut output = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Rogue Agenda — {}</title><style>body{{font:16px monospace;background:#c0c0c0;color:#000}}h1,h2{{background:#000080;color:white;padding:.35rem}}table{{border-collapse:collapse;width:100%;margin-bottom:1.5rem}}th{{color:#000080;text-align:left}}th,td{{border-bottom:1px solid #888;padding:.3rem;vertical-align:top}}</style></head><body><h1>Rogue Agenda — {}</h1>", "<!doctype html><html><head><meta charset=\"utf-8\"><title>Rogue Agenda — {}</title><style>body{{font:16px monospace;background:#c0c0c0;color:#000}}h1,h2{{background:#000080;color:white;padding:.35rem}}table{{border-collapse:collapse;width:100%;margin-bottom:1.5rem;break-inside:auto}}thead{{display:table-header-group}}tfoot{{display:table-footer-group;font-weight:bold}}tr{{break-inside:avoid}}th{{color:#000080}}th,td{{border-bottom:1px solid #888;padding:.3rem;vertical-align:top;overflow-wrap:anywhere}}@media print{{body{{background:white}}h1,h2{{color:black;background:white;border-bottom:2px solid black}}section{{break-inside:avoid-page}}}}</style></head><body>",
html(&view.name), html(&view.name),
html(&view.name)
); );
if show_headers {
output.push_str(&format!(
"<header><h1>Rogue Agenda — {}</h1><p>{}</p><p>View: {} · Filters: <code>{}</code> · Generated: {}</p></header>",
html(&view.name),
html(description),
html(&view.name),
html(&filter_summary(view)),
html(&generated),
));
}
for (heading, group) in report_groups(view, items) { for (heading, group) in report_groups(view, items) {
output.push_str("<section>");
if !heading.is_empty() { if !heading.is_empty() {
output.push_str(&format!("<h2>{}</h2>", html(&heading))); output.push_str(&format!("<h2>{}</h2>", html(&heading)));
} }
output.push_str("<table><thead><tr>"); output.push_str("<table><colgroup>");
for column in &view.columns { for column in report_columns(view) {
output.push_str(&format!("<th>{}</th>", html(&column.heading))); output.push_str(&format!("<col style=\"width:{}%\">", column.width()));
}
output.push_str("</colgroup><thead><tr>");
for column in report_columns(view) {
output.push_str(&format!(
"<th style=\"text-align:{}\">{}</th>",
column.alignment(),
html(&column.heading())
));
} }
output.push_str("</tr></thead><tbody>"); output.push_str("</tr></thead><tbody>");
for item in group { for item in &group {
output.push_str("<tr>"); output.push_str("<tr>");
for column in &view.columns { for column in report_columns(view) {
output.push_str(&format!( output.push_str(&format!(
"<td>{}</td>", "<td style=\"text-align:{}\">{}</td>",
html(&report_value(&column.field, item)).replace('\n', "<br>") column.alignment(),
html(&column.value(item, &group)).replace('\n', "<br>")
)); ));
} }
output.push_str("</tr>"); output.push_str("</tr>");
} }
output.push_str("</tbody></table>"); output.push_str("</tbody>");
if view
.columns
.iter()
.any(|column| column.aggregate != crate::model::Aggregate::None)
{
output.push_str("<tfoot><tr>");
for column in report_columns(view) {
output.push_str(&format!(
"<td style=\"text-align:{}\">{}</td>",
column.alignment(),
html(&column.aggregate(&group))
));
}
output.push_str("</tr></tfoot>");
}
output.push_str("</table></section>");
}
if show_headers {
output.push_str(&format!(
"<footer>Rogue Agenda · {} · {generated}</footer>",
html(&view.name)
));
} }
output.push_str("</body></html>\n"); output.push_str("</body></html>\n");
output output
} }
fn filter_summary(view: &ViewDef) -> String {
format!(
"kind={} value={} expression={} show_done={}",
view.kind, view.filter_value, view.filter_expr, view.show_done
)
}
fn report_groups<'a>(view: &ViewDef, items: &'a [Item]) -> Vec<(String, Vec<&'a Item>)> { fn report_groups<'a>(view: &ViewDef, items: &'a [Item]) -> Vec<(String, Vec<&'a Item>)> {
if view.sections.is_empty() { if view.sections.is_empty() {
return vec![(String::new(), items.iter().collect())]; return vec![(String::new(), items.iter().collect())];
@@ -177,24 +261,72 @@ fn report_groups<'a>(view: &ViewDef, items: &'a [Item]) -> Vec<(String, Vec<&'a
.collect() .collect()
} }
fn report_value(field: &str, item: &Item) -> String { enum ReportColumn<'a> {
match field { Value(&'a crate::model::ViewColumn),
"item" => item.text.clone(), Percent(&'a crate::model::ViewColumn),
"categories" => item.category_names(), }
"when" => item.when_at.clone().unwrap_or_default(),
"priority" => item.priority.to_string(), impl ReportColumn<'_> {
"note" => item.note.clone(), fn heading(&self) -> String {
"value" => item match self {
.numeric_value Self::Value(column) => column.heading.clone(),
.map(|value| value.to_string()) Self::Percent(column) => format!("{} %", column.heading),
.unwrap_or_default(), }
"done" => item.done_at.clone().unwrap_or_default(),
"alarm" => item.alarm_at.clone().unwrap_or_default(),
"recurrence" => item.recurrence.clone(),
"created" => item.created_at.clone(),
"updated" => item.updated_at.clone(),
_ => String::new(),
} }
fn alignment(&self) -> &str {
match self {
Self::Value(column) => &column.alignment,
Self::Percent(_) => "right",
}
}
fn width(&self) -> u16 {
match self {
Self::Value(column) => column.width,
Self::Percent(_) => 10,
}
}
fn value(&self, item: &Item, section: &[&Item]) -> String {
match self {
Self::Value(column) => column
.numeric_value(item)
.map(|value| column.format_number(value))
.unwrap_or_else(|| column.raw_value(item)),
Self::Percent(column) => {
let total = section
.iter()
.filter_map(|item| column.numeric_value(item))
.sum::<f64>();
column
.numeric_value(item)
.filter(|_| total != 0.0)
.map(|value| format!("{:.2}%", value * 100.0 / total))
.unwrap_or_default()
}
}
}
fn aggregate(&self, section: &[&Item]) -> String {
match self {
Self::Value(column) => column.aggregate_value(section.iter().copied()),
Self::Percent(_) => String::new(),
}
}
}
fn report_columns(view: &ViewDef) -> Vec<ReportColumn<'_>> {
view.columns
.iter()
.flat_map(|column| {
std::iter::once(ReportColumn::Value(column)).chain(
column
.percent_total
.then_some(ReportColumn::Percent(column)),
)
})
.collect()
} }
fn markdown(value: &str) -> String { fn markdown(value: &str) -> String {

View File

@@ -5,14 +5,16 @@ use chrono::{Duration, Local, TimeZone};
use clap::ValueEnum; use clap::ValueEnum;
use rusqlite::params; use rusqlite::params;
use super::{Database, parse_action_spec, parse_columns_spec, parse_sections_spec}; use super::{
Database, parse_action_spec, parse_bound_field, parse_columns_spec, parse_sections_spec,
};
use crate::{ use crate::{
filter, filter,
macro_lang::{MacroRuntime, macro_name, parse_key_binding}, macro_lang::{MacroRuntime, macro_name, parse_key_binding},
model::MacroDef, model::MacroDef,
}; };
const PRESET_VERSION: &str = "1"; const PRESET_VERSION: &str = "2";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Preset { pub enum Preset {
@@ -282,14 +284,22 @@ impl Database {
let view_id = tx.last_insert_rowid(); let view_id = tx.last_insert_rowid();
for (column_order, column) in parse_columns_spec(view.columns)?.iter().enumerate() { for (column_order, column) in parse_columns_spec(view.columns)?.iter().enumerate() {
tx.execute( tx.execute(
"INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order) "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)", VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
params![ params![
view_id, view_id,
column.field, column.field,
column.category.as_deref().map(|name| category_ids[name]),
column.heading, column.heading,
column.width, column.width,
column.aggregate.as_str(), 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,
column_order as i64 column_order as i64
], ],
)?; )?;
@@ -344,15 +354,14 @@ impl Database {
.transpose()?; .transpose()?;
let done_at = item.done.then(|| now.clone()); let done_at = item.done.then(|| now.clone());
tx.execute( tx.execute(
"INSERT INTO items(text,note,priority,when_at,done_at,numeric_value,recurrence,created_at,updated_at,sort_order) "INSERT INTO items(text,note,priority,when_at,done_at,recurrence,created_at,updated_at,sort_order)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,?9)", VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8)",
params![ params![
item.text, item.text,
item.note, item.note,
item.priority, item.priority,
when_at, when_at,
done_at, done_at,
item.numeric_value,
item.recurrence, item.recurrence,
now, now,
order as i64 order as i64
@@ -365,6 +374,26 @@ impl Database {
params![item_id, category_ids[category]], params![item_id, category_ids[category]],
)?; )?;
} }
if let Some(value) = item.numeric_value {
let category = item
.categories
.iter()
.find(|name| {
spec.categories
.iter()
.any(|category| category.name == **name && category.kind == "numeric")
})
.with_context(|| {
format!(
"preset item {:?} has a value but no numeric category",
item.text
)
})?;
tx.execute(
"INSERT INTO item_values(item_id,category_id,numeric_value,assignment) VALUES(?1,?2,?3,'explicit')",
params![item_id,category_ids[*category],value],
)?;
}
} }
for (key, value) in [ for (key, value) in [
@@ -420,7 +449,38 @@ fn validate_spec(spec: &PresetSpec) -> Result<()> {
bail!("preset has duplicate view {:?}", view.name); bail!("preset has duplicate view {:?}", view.name);
} }
filter::parse(view.filter_expr)?; filter::parse(view.filter_expr)?;
parse_columns_spec(view.columns)?; for column in parse_columns_spec(view.columns)? {
if let Some(category_name) = column.category {
let category = spec
.categories
.iter()
.find(|category| category.name == category_name)
.with_context(|| {
format!("preset view column category {category_name:?} is missing")
})?;
if category.kind != column.field {
bail!(
"preset view column category {:?} is {}, not {}",
category_name,
category.kind,
column.field
);
}
}
}
if let Some((field, Some(category_name))) = parse_bound_field(view.sort_key) {
let category = spec
.categories
.iter()
.find(|category| category.name == category_name)
.with_context(|| format!("preset sort category {category_name:?} is missing"))?;
if category.kind != field {
bail!(
"preset sort category {category_name:?} is {}, not {field}",
category.kind
);
}
}
parse_sections_spec(view.sections)?; parse_sections_spec(view.sections)?;
} }
@@ -522,7 +582,7 @@ fn accounts() -> PresetSpec {
ViewSpec::new("Expenses") ViewSpec::new("Expenses")
.category("Expenses") .category("Expenses")
.sort("updated") .sort("updated")
.columns("item:42:Expense,categories:28:Account,value:14:Amount:sum,note:30:Details"), .columns("item:42:Expense,categories:28:Account,numeric[Expenses]:14:Amount:sum:$:2:.:comma:parentheses:percent:right,note:30:Details"),
ViewSpec::new("Status Report") ViewSpec::new("Status Report")
.category("Status Report") .category("Status Report")
.sort("priority") .sort("priority")
@@ -580,8 +640,8 @@ fn accounts() -> PresetSpec {
], ],
views, views,
rules: vec![ rules: vec![
rule("Status Report", "priority=1 and open", "assign:"), rule("Status Report", "priority=1 and open", ""),
rule("Follow-ups", "text~follow-up", "assign:"), rule("Follow-ups", "text~follow-up", ""),
], ],
macros: vec![ macros: vec![
agenda_macro( agenda_macro(
@@ -649,7 +709,7 @@ fn study() -> PresetSpec {
.sort("when") .sort("when")
.hide_done() .hide_done()
.filter("category=Cards and (overdue or due=7d)") .filter("category=Cards and (overdue or due=7d)")
.columns("item:55:Question,categories:28:Deck / stage,when:20:Review,value:8:Cycle"), .columns("item:55:Question,categories:28:Deck / stage,when:20:Review,numeric[Cycle]:8:Cycle:none::0:.:none:minus:value:right"),
ViewSpec::new("New Cards") ViewSpec::new("New Cards")
.category("New") .category("New")
.sort("manual") .sort("manual")
@@ -658,7 +718,7 @@ fn study() -> PresetSpec {
ViewSpec::new("All Cards") ViewSpec::new("All Cards")
.category("Cards") .category("Cards")
.sort("when") .sort("when")
.columns("item:55:Question,categories:28:Deck / stage,when:20:Review,value:8:Cycle"), .columns("item:55:Question,categories:28:Deck / stage,when:20:Review,numeric[Cycle]:8:Cycle:none::0:.:none:minus:value:right"),
ViewSpec::new("Deck Browser") ViewSpec::new("Deck Browser")
.category("Cards") .category("Cards")
.columns("item:52:Question,note:42:Answer,categories:28:Deck") .columns("item:52:Question,note:42:Answer,categories:28:Deck")
@@ -683,7 +743,7 @@ fn study() -> PresetSpec {
category("Cycle", None, "numeric", "", false), category("Cycle", None, "numeric", "", false),
], ],
views, views,
rules: vec![rule("Cards", "text~q:", "assign:")], rules: vec![rule("Cards", "text~q:", "")],
macros: vec![ macros: vec![
agenda_macro( agenda_macro(
"capturecard", "capturecard",
@@ -837,15 +897,15 @@ fn planner() -> PresetSpec {
), ),
category("Ideas", None, "standard", "idea:,maybe", false), category("Ideas", None, "standard", "idea:,maybe", false),
category("When", None, "date", "", false), category("When", None, "date", "", false),
category("Priority", None, "numeric", "", false), category("Priority", None, "standard", "", false),
], ],
views, views,
rules: vec![ rules: vec![
rule("Next Actions", "priority<=2 and open", "assign:"), rule("Next Actions", "priority<=2 and open", ""),
rule( rule(
"Scheduled Activity", "Scheduled Activity",
"dated and (category=Calls or category=Meetings)", "dated and (category=Calls or category=Meetings)",
"assign:", "",
), ),
], ],
macros: vec![ macros: vec![
@@ -912,22 +972,22 @@ fn recipes() -> PresetSpec {
let mut views = vec![ let mut views = vec![
ViewSpec::new("Recipe Box") ViewSpec::new("Recipe Box")
.category("Recipes") .category("Recipes")
.columns("item:42:Recipe,categories:35:Course / cuisine,note:55:Ingredients and method,value:10:Serves"), .columns("item:42:Recipe,categories:35:Course / cuisine,note:55:Ingredients and method,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right"),
ViewSpec::new("Favorites") ViewSpec::new("Favorites")
.category("Favorites") .category("Favorites")
.columns("item:45:Favorite,categories:35:Tags,note:58:Recipe,value:10:Serves"), .columns("item:45:Favorite,categories:35:Tags,note:58:Recipe,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right"),
ViewSpec::new("Main Dishes") ViewSpec::new("Main Dishes")
.category("Main Dishes") .category("Main Dishes")
.columns("item:45:Main dish,categories:35:Tags,note:58:Recipe,value:10:Serves"), .columns("item:45:Main dish,categories:35:Tags,note:58:Recipe,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right"),
ViewSpec::new("Quick Meals") ViewSpec::new("Quick Meals")
.category("Quick") .category("Quick")
.columns("item:45:Recipe,categories:35:Tags,note:58:Method,value:10:Serves"), .columns("item:45:Recipe,categories:35:Tags,note:58:Method,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right"),
ViewSpec::new("Vegetarian") ViewSpec::new("Vegetarian")
.category("Vegetarian") .category("Vegetarian")
.columns("item:45:Recipe,categories:35:Tags,note:58:Recipe,value:10:Serves"), .columns("item:45:Recipe,categories:35:Tags,note:58:Recipe,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right"),
ViewSpec::new("By Course") ViewSpec::new("By Course")
.category("Recipes") .category("Recipes")
.columns("item:45:Recipe,categories:35:Tags,value:10:Serves") .columns("item:45:Recipe,categories:35:Tags,numeric[Servings]:10:Serves:none::0:.:none:minus:value:right")
.sections("Breakfast|category=Breakfast;Main dishes|category='Main Dishes';Desserts|category=Desserts;Drinks|category=Drinks"), .sections("Breakfast|category=Breakfast;Main dishes|category='Main Dishes';Desserts|category=Desserts;Drinks|category=Drinks"),
ViewSpec::new("All Items").sort("updated"), ViewSpec::new("All Items").sort("updated"),
]; ];
@@ -960,8 +1020,8 @@ fn recipes() -> PresetSpec {
], ],
views, views,
rules: vec![ rules: vec![
rule("Recipes", "text~recipe:", "assign:"), rule("Recipes", "text~recipe:", ""),
rule("Quick", "note~minutes", "assign:"), rule("Quick", "note~minutes", ""),
], ],
macros: vec![ macros: vec![
agenda_macro( agenda_macro(
@@ -1013,19 +1073,19 @@ fn rides() -> PresetSpec {
ViewSpec::new("Ride Log") ViewSpec::new("Ride Log")
.category("Rides") .category("Rides")
.sort("when") .sort("when")
.columns("item:45:Ride,categories:35:Bike / type / weather,when:20:Date,value:14:Distance km:sum,note:35:Notes"), .columns("item:45:Ride,categories:35:Bike / type / weather,when:20:Date,numeric[Distance]:14:Distance km:sum::1:.:comma:minus:percent:right,note:35:Notes"),
ViewSpec::new("Training") ViewSpec::new("Training")
.category("Training") .category("Training")
.sort("when") .sort("when")
.columns("item:48:Session,categories:32:Bike / route,when:20:Date,value:14:Distance km:sum"), .columns("item:48:Session,categories:32:Bike / route,when:20:Date,numeric[Distance]:14:Distance km:sum::1:.:comma:minus:percent:right"),
ViewSpec::new("Long Rides") ViewSpec::new("Long Rides")
.category("Rides") .category("Rides")
.filter("value>=50") .filter("value>=50")
.sort("updated") .sort("updated")
.columns("item:48:Ride,categories:35:Bike / route,when:20:Date,value:14:Distance km:avg"), .columns("item:48:Ride,categories:35:Bike / route,when:20:Date,numeric[Distance]:14:Distance km:avg::1:.:comma:minus:value:right"),
ViewSpec::new("By Bicycle") ViewSpec::new("By Bicycle")
.category("Rides") .category("Rides")
.columns("item:45:Ride,when:20:Date,value:14:Distance km:sum,categories:35:Type / weather") .columns("item:45:Ride,when:20:Date,numeric[Distance]:14:Distance km:sum::1:.:comma:minus:percent:right,categories:35:Type / weather")
.sections("Road bike|category='Road Bike';Touring bike|category='Touring Bike';City bike|category='City Bike'"), .sections("Road bike|category='Road Bike';Touring bike|category='Touring Bike';City bike|category='City Bike'"),
ViewSpec::new("Maintenance") ViewSpec::new("Maintenance")
.category("Maintenance") .category("Maintenance")
@@ -1115,8 +1175,8 @@ fn rides() -> PresetSpec {
], ],
views, views,
rules: vec![ rules: vec![
rule("Rides", "text~ride:", "assign:"), rule("Rides", "text~ride:", ""),
rule("Maintenance", "text~service or text~replace", "assign:"), rule("Maintenance", "text~service or text~replace", ""),
], ],
macros: vec![ macros: vec![
agenda_macro( agenda_macro(
@@ -1213,7 +1273,7 @@ fn people() -> PresetSpec {
.category("Goals") .category("Goals")
.sort("priority") .sort("priority")
.hide_done() .hide_done()
.columns("item:52:Goal,categories:34:Person / team,when:20:Target,priority:5:P,note:40:Evidence"), .columns("item:52:Goal,categories:34:Person / team,when:20:Target,numeric[Progress]:12:Progress:none:%:0:.:none:minus:value:right,priority:5:P,note:40:Evidence"),
ViewSpec::new("Development") ViewSpec::new("Development")
.category("Development") .category("Development")
.sort("when") .sort("when")
@@ -1254,9 +1314,9 @@ fn people() -> PresetSpec {
], ],
views, views,
rules: vec![ rules: vec![
rule("People Records", "text~person:", "assign:"), rule("People Records", "text~person:", ""),
rule("Reviews", "text~review:", "assign:"), rule("Reviews", "text~review:", ""),
rule("Goals", "text~goal:", "assign:"), rule("Goals", "text~goal:", ""),
], ],
macros: vec![ macros: vec![
agenda_macro( agenda_macro(
@@ -1346,6 +1406,34 @@ mod tests {
assert!(database.categories().unwrap().len() >= 10); assert!(database.categories().unwrap().len() >= 10);
assert!(database.views().unwrap().len() >= 7); assert!(database.views().unwrap().len() >= 7);
assert!(database.macros().unwrap().len() >= 2); assert!(database.macros().unwrap().len() >= 2);
let unbound_numeric_columns: i64 = database
.conn
.query_row(
"SELECT COUNT(*) FROM view_columns WHERE field='numeric' AND category_id IS NULL",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
unbound_numeric_columns,
0,
"{} has an unbound numeric column",
preset.name()
);
let misplaced_values: i64 = database
.conn
.query_row(
"SELECT COUNT(*) FROM item_values v JOIN categories c ON c.id=v.category_id WHERE v.numeric_value IS NOT NULL AND c.kind<>'numeric'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
misplaced_values,
0,
"{} has a value outside a numeric category",
preset.name()
);
assert_eq!( assert_eq!(
database database
.conn .conn

View File

@@ -98,6 +98,114 @@ pub(super) fn migrate(connection: &Connection) -> Result<()> {
if version < 4 { if version < 4 {
connection.pragma_update(None, "user_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(()) Ok(())
} }
@@ -124,3 +232,63 @@ fn column_exists(connection: &Connection, table: &str, column: &str) -> Result<b
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
Ok(names.iter().any(|name| name == column)) 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
);
}
}

View File

@@ -1,4 +1,4 @@
use anyhow::{Result, bail}; use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Local, TimeZone}; use chrono::{DateTime, Duration, Local, TimeZone};
use crate::model::Item; use crate::model::Item;
@@ -19,6 +19,10 @@ pub enum Predicate {
Category(String), Category(String),
Priority(Compare, i64), Priority(Compare, i64),
Value(Compare, f64), Value(Compare, f64),
NumericValue(String, Compare, f64),
NumericRange(String, f64, f64, bool),
DateValue(String, Compare, String),
DateRange(String, String, String, bool),
Done(bool), Done(bool),
HasNote, HasNote,
HasDate(bool), HasDate(bool),
@@ -75,6 +79,20 @@ fn predicate(p: &Predicate, item: &Item) -> bool {
.any(|c| c.name.eq_ignore_ascii_case(s)), .any(|c| c.name.eq_ignore_ascii_case(s)),
Predicate::Priority(op, n) => compare_i64(item.priority, *op, *n), Predicate::Priority(op, n) => compare_i64(item.priority, *op, *n),
Predicate::Value(op, n) => item.numeric_value.is_some_and(|v| compare_f64(v, *op, *n)), Predicate::Value(op, n) => item.numeric_value.is_some_and(|v| compare_f64(v, *op, *n)),
Predicate::NumericValue(category, op, number) => item
.numeric_value_for(category)
.is_some_and(|value| compare_f64(value, *op, *number)),
Predicate::NumericRange(category, minimum, maximum, inside) => item
.numeric_value_for(category)
.is_some_and(|value| ((value >= *minimum) && (value <= *maximum)) == *inside),
Predicate::DateValue(category, op, expected) => item
.date_value_for(category)
.is_some_and(|value| compare_str(value, *op, expected)),
Predicate::DateRange(category, minimum, maximum, inside) => {
item.date_value_for(category).is_some_and(|value| {
((value >= minimum.as_str()) && (value <= maximum.as_str())) == *inside
})
}
Predicate::Done(expected) => item.done_at.is_some() == *expected, Predicate::Done(expected) => item.done_at.is_some() == *expected,
Predicate::HasNote => !item.note.trim().is_empty(), Predicate::HasNote => !item.note.trim().is_empty(),
Predicate::HasDate(expected) => item.when_at.is_some() == *expected, Predicate::HasDate(expected) => item.when_at.is_some() == *expected,
@@ -126,6 +144,17 @@ fn compare_f64(a: f64, op: Compare, b: f64) -> bool {
} }
} }
fn compare_str(a: &str, op: Compare, b: &str) -> bool {
match op {
Compare::Eq => a == b,
Compare::Ne => a != b,
Compare::Lt => a < b,
Compare::Le => a <= b,
Compare::Gt => a > b,
Compare::Ge => a >= b,
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
enum Token { enum Token {
Word(String), Word(String),
@@ -301,6 +330,34 @@ impl Parser {
let op = self.required_op()?; let op = self.required_op()?;
Ok(Predicate::Value(op, self.word()?.parse()?)) Ok(Predicate::Value(op, self.word()?.parse()?))
} }
field if field.starts_with("numeric:") => {
let category = if field.len() == "numeric:".len() {
self.word()?
} else {
field["numeric:".len()..].to_owned()
};
if let Some(inside) = self.range_operator() {
let (minimum, maximum) = parse_range::<f64>(&self.word()?)?;
Ok(Predicate::NumericRange(category, minimum, maximum, inside))
} else {
let op = self.required_op()?;
Ok(Predicate::NumericValue(category, op, self.word()?.parse()?))
}
}
field if field.starts_with("date:") => {
let category = if field.len() == "date:".len() {
self.word()?
} else {
field["date:".len()..].to_owned()
};
if let Some(inside) = self.range_operator() {
let (minimum, maximum) = parse_range::<String>(&self.word()?)?;
Ok(Predicate::DateRange(category, minimum, maximum, inside))
} else {
let op = self.required_op()?;
Ok(Predicate::DateValue(category, op, self.word()?))
}
}
"due" => { "due" => {
let _ = self.optional_op(); let _ = self.optional_op();
let raw = self.word()?.to_lowercase(); let raw = self.word()?.to_lowercase();
@@ -329,6 +386,19 @@ impl Parser {
_ => bail!("expected a comparison operator"), _ => bail!("expected a comparison operator"),
} }
} }
fn range_operator(&mut self) -> Option<bool> {
let Some(Token::Word(operator)) = self.tokens.get(self.at) else {
return None;
};
let inside = match operator.to_lowercase().as_str() {
"inside" => true,
"outside" => false,
_ => return None,
};
self.at += 1;
Some(inside)
}
fn optional_op(&mut self) -> Option<Compare> { fn optional_op(&mut self) -> Option<Compare> {
if let Some(Token::Op(op)) = self.tokens.get(self.at).cloned() { if let Some(Token::Op(op)) = self.tokens.get(self.at).cloned() {
self.at += 1; self.at += 1;
@@ -347,6 +417,17 @@ impl Parser {
} }
} }
fn parse_range<T>(source: &str) -> Result<(T, T)>
where
T: std::str::FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
let (minimum, maximum) = source
.split_once("..")
.context("ranges must use minimum..maximum")?;
Ok((minimum.parse()?, maximum.parse()?))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -365,6 +446,7 @@ mod tests {
created_at: String::new(), created_at: String::new(),
updated_at: String::new(), updated_at: String::new(),
discarded: false, discarded: false,
values: vec![],
categories: vec![Category { categories: vec![Category {
id: 1, id: 1,
name: "Work".into(), name: "Work".into(),

View File

@@ -10,7 +10,7 @@ pub use enums::{
Aggregate, CategoryKind, DateOrder, DonePolicy, RuleActionKind, SortKey, TrashPolicy, ViewKind, Aggregate, CategoryKind, DateOrder, DonePolicy, RuleActionKind, SortKey, TrashPolicy, ViewKind,
WeekStart, WeekStart,
}; };
pub use item::{Item, ItemChanges}; pub use item::{CategoryValue, Item, ItemChanges};
pub use macro_def::MacroDef; pub use macro_def::MacroDef;
pub use settings::DocumentSettings; pub use settings::DocumentSettings;
pub use view::{ViewColumn, ViewDef, ViewSection}; pub use view::{ViewColumn, ViewDef, ViewSection};

View File

@@ -66,15 +66,34 @@ string_enum!(Aggregate {
Maximum => "max", Maximum => "max",
}); });
impl Aggregate {
pub const fn label(self) -> &'static str {
match self {
Self::None => "",
Self::Sum => "Total",
Self::Average => "Average",
Self::Count => "Count",
Self::Minimum => "Minimum",
Self::Maximum => "Maximum",
}
}
}
string_enum!(RuleActionKind { string_enum!(RuleActionKind {
None => "none",
Assign => "assign", Assign => "assign",
Exclude => "exclude", Exclude => "exclude",
Remove => "remove",
Priority => "priority", Priority => "priority",
Value => "value", Value => "value",
Numeric => "numeric",
Date => "date",
When => "when", When => "when",
Alarm => "alarm", Alarm => "alarm",
Repeat => "repeat", Repeat => "repeat",
Done => "done", Done => "done",
Export => "export",
Discard => "discard",
}); });
string_enum!(TrashPolicy { string_enum!(TrashPolicy {

View File

@@ -1,4 +1,14 @@
use super::Category; use super::{Category, CategoryKind};
#[derive(Debug, Clone, PartialEq)]
pub struct CategoryValue {
pub category_id: i64,
pub category_name: String,
pub kind: CategoryKind,
pub numeric_value: Option<f64>,
pub date_value: Option<String>,
pub assignment: String,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Item { pub struct Item {
@@ -15,6 +25,7 @@ pub struct Item {
pub updated_at: String, pub updated_at: String,
pub discarded: bool, pub discarded: bool,
pub categories: Vec<Category>, pub categories: Vec<Category>,
pub values: Vec<CategoryValue>,
} }
impl Item { impl Item {
@@ -25,6 +36,34 @@ impl Item {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
} }
pub fn numeric_value_for(&self, category: &str) -> Option<f64> {
self.values
.iter()
.find(|value| {
value.kind == CategoryKind::Numeric
&& value.category_name.eq_ignore_ascii_case(category)
&& self
.categories
.iter()
.any(|assigned| assigned.id == value.category_id)
})
.and_then(|value| value.numeric_value)
}
pub fn date_value_for(&self, category: &str) -> Option<&str> {
self.values
.iter()
.find(|value| {
value.kind == CategoryKind::Date
&& value.category_name.eq_ignore_ascii_case(category)
&& self
.categories
.iter()
.any(|assigned| assigned.id == value.category_id)
})
.and_then(|value| value.date_value.as_deref())
}
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]

View File

@@ -14,6 +14,7 @@ pub struct DocumentSettings {
pub afternoon_time: String, pub afternoon_time: String,
pub evening_time: String, pub evening_time: String,
pub note_tab_width: u8, pub note_tab_width: u8,
pub report_headers: bool,
} }
impl Default for DocumentSettings { impl Default for DocumentSettings {
@@ -31,6 +32,7 @@ impl Default for DocumentSettings {
afternoon_time: "13:00".into(), afternoon_time: "13:00".into(),
evening_time: "18:00".into(), evening_time: "18:00".into(),
note_tab_width: 4, note_tab_width: 4,
report_headers: true,
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use super::{Aggregate, SortKey, ViewKind}; use super::{Aggregate, Item, ViewKind};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ViewDef { pub struct ViewDef {
@@ -6,7 +6,7 @@ pub struct ViewDef {
pub name: String, pub name: String,
pub kind: ViewKind, pub kind: ViewKind,
pub filter_value: String, pub filter_value: String,
pub sort_key: SortKey, pub sort_key: String,
pub show_done: bool, pub show_done: bool,
pub filter_expr: String, pub filter_expr: String,
pub columns: Vec<ViewColumn>, pub columns: Vec<ViewColumn>,
@@ -16,9 +16,17 @@ pub struct ViewDef {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct ViewColumn { pub struct ViewColumn {
pub field: String, pub field: String,
pub category: Option<String>,
pub heading: String, pub heading: String,
pub width: u16, pub width: u16,
pub aggregate: Aggregate, pub aggregate: Aggregate,
pub number_label: String,
pub decimals: u8,
pub decimal_separator: String,
pub thousands_separator: String,
pub negative_style: String,
pub percent_total: bool,
pub alignment: String,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -35,8 +43,31 @@ impl ViewDef {
.iter() .iter()
.map(|column| { .map(|column| {
format!( format!(
"{}:{}:{}:{}", "{}{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
column.field, column.width, column.heading, column.aggregate column.field,
column
.category
.as_ref()
.map(|category| format!("[{category}]"))
.unwrap_or_default(),
column.width,
column.heading,
column.aggregate,
column.number_label,
column.decimals,
match column.decimal_separator.as_str() {
"," => "comma",
"." => "dot",
other => other,
},
column.thousands_separator,
column.negative_style,
if column.percent_total {
"percent"
} else {
"value"
},
column.alignment,
) )
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -58,3 +89,121 @@ impl ViewDef {
.join(";") .join(";")
} }
} }
impl ViewColumn {
pub fn raw_value(&self, item: &Item) -> String {
match self.field.as_str() {
"item" => item.text.clone(),
"categories" => item.category_names(),
"when" => item.when_at.clone().unwrap_or_default(),
"priority" => item.priority.to_string(),
"note" => item.note.clone(),
"numeric" => self
.category
.as_deref()
.and_then(|category| item.numeric_value_for(category))
.map(|value| value.to_string())
.unwrap_or_default(),
"value" => self
.category
.as_deref()
.map_or(item.numeric_value, |category| {
item.numeric_value_for(category)
})
.map(|value| value.to_string())
.unwrap_or_default(),
"date" => self
.category
.as_deref()
.and_then(|category| item.date_value_for(category))
.unwrap_or_default()
.into(),
"done" => item.done_at.clone().unwrap_or_default(),
"alarm" => item.alarm_at.clone().unwrap_or_default(),
"recurrence" => item.recurrence.clone(),
"created" => item.created_at.clone(),
"updated" => item.updated_at.clone(),
_ => String::new(),
}
}
pub fn numeric_value(&self, item: &Item) -> Option<f64> {
match self.field.as_str() {
"numeric" => self
.category
.as_deref()
.and_then(|category| item.numeric_value_for(category)),
"value" => self
.category
.as_deref()
.map_or(item.numeric_value, |category| {
item.numeric_value_for(category)
}),
"priority" => Some(item.priority as f64),
_ => None,
}
}
pub fn format_number(&self, value: f64) -> String {
let negative = value.is_sign_negative();
let raw = format!("{:.*}", self.decimals as usize, value.abs());
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, ""));
let separator = match self.thousands_separator.as_str() {
"comma" => ",",
"dot" => ".",
"space" => " ",
"none" | "" => "",
other => other,
};
let mut grouped = String::new();
for (index, character) in integer.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
grouped.push_str(separator);
}
grouped.push(character);
}
let mut number = grouped.chars().rev().collect::<String>();
if !fraction.is_empty() {
number.push_str(&self.decimal_separator);
number.push_str(fraction);
}
if !self.number_label.is_empty() {
number = if matches!(self.number_label.as_str(), "$" | "" | "£" | "¥") {
format!("{}{number}", self.number_label)
} else {
format!("{number} {}", self.number_label)
};
}
if negative {
number = match self.negative_style.as_str() {
"parentheses" => format!("({number})"),
"trailing" => format!("{number}-"),
_ => format!("-{number}"),
};
}
number
}
pub fn aggregate_value<'a>(&self, items: impl Iterator<Item = &'a Item>) -> String {
if self.aggregate == Aggregate::None {
return String::new();
}
let values = items
.filter_map(|item| self.numeric_value(item))
.collect::<Vec<_>>();
if self.aggregate == Aggregate::Count {
return format!("Count {}", values.len());
}
if values.is_empty() {
return String::new();
}
let value = match self.aggregate {
Aggregate::Sum => values.iter().sum(),
Aggregate::Average => values.iter().sum::<f64>() / values.len() as f64,
Aggregate::Minimum => values.iter().copied().fold(f64::INFINITY, f64::min),
Aggregate::Maximum => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
Aggregate::None | Aggregate::Count => unreachable!(),
};
format!("{} {}", self.aggregate.label(), self.format_number(value))
}
}

371
src/ui.rs
View File

@@ -146,6 +146,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
area, area,
); );
app.item_rows.clear(); app.item_rows.clear();
app.item_columns.clear();
if app.items.is_empty() { if app.items.is_empty() {
frame.render_widget( frame.render_widget(
Paragraph::new(if app.search.is_empty() { Paragraph::new(if app.search.is_empty() {
@@ -162,32 +163,78 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
draw_datebook(frame, app, area); draw_datebook(frame, app, area);
return; return;
} }
let mut columns = app.current_view().columns.clone(); let mut base_columns = app
if area.width < 70 && columns.len() > 2 { .current_view()
let item = columns .columns
.iter()
.cloned()
.enumerate()
.collect::<Vec<_>>();
if area.width < 70 && base_columns.len() > 2 {
let item = base_columns
.iter() .iter()
.find(|c| c.field == "item") .find(|(_, column)| column.field == "item")
.cloned() .cloned()
.unwrap_or_else(|| columns[0].clone()); .unwrap_or_else(|| base_columns[0].clone());
let detail = columns let detail = base_columns
.iter() .iter()
.find(|c| c.field == "when") .find(|(_, column)| column.field == "when")
.cloned() .cloned()
.or_else(|| columns.iter().find(|c| c.field != item.field).cloned()); .or_else(|| {
columns = vec![item]; base_columns
.iter()
.find(|(_, column)| column.field != item.1.field)
.cloned()
});
base_columns = vec![item];
if let Some(detail) = detail { if let Some(detail) = detail {
columns.push(detail) base_columns.push(detail)
} }
} }
let columns = base_columns
.into_iter()
.flat_map(|(base_index, column)| {
let percent = column.percent_total.then(|| DisplayColumn {
column: column.clone(),
percent: true,
base_index,
});
std::iter::once(DisplayColumn {
column,
percent: false,
base_index,
})
.chain(percent)
})
.collect::<Vec<_>>();
let widths = column_widths(&columns, area.width); let widths = column_widths(&columns, area.width);
let mut x = area.x;
for (column, width) in columns.iter().zip(&widths) {
app.item_columns.push((
Rect::new(
x,
area.y,
(*width).min(u16::MAX as usize) as u16,
area.height,
),
column.base_index,
));
x = x.saturating_add((*width).min(u16::MAX as usize) as u16 + 1);
}
let mut display: Vec<DisplayLine> = vec![]; let mut display: Vec<DisplayLine> = vec![];
let sections = app.current_view().sections.clone(); let sections = app.current_view().sections.clone();
if sections.is_empty() { if sections.is_empty() {
display.push(DisplayLine::Header); display.push(DisplayLine::Header);
for index in 0..app.items.len() { for index in 0..app.items.len() {
display.push(DisplayLine::Item(index)); display.push(DisplayLine::Item {
index,
section: (0..app.items.len()).collect(),
});
} }
if columns.iter().any(|c| c.aggregate != Aggregate::None) { if columns
.iter()
.any(|column| column.column.aggregate != Aggregate::None)
{
display.push(DisplayLine::Aggregate((0..app.items.len()).collect())); display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
} }
} else { } else {
@@ -209,16 +256,22 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.map(|(i, _)| i) .map(|(i, _)| i)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for index in &indices { for index in &indices {
display.push(DisplayLine::Item(*index)); display.push(DisplayLine::Item {
index: *index,
section: indices.clone(),
});
} }
if columns.iter().any(|c| c.aggregate != Aggregate::None) { if columns
.iter()
.any(|column| column.column.aggregate != Aggregate::None)
{
display.push(DisplayLine::Aggregate(indices)); display.push(DisplayLine::Aggregate(indices));
} }
} }
} }
let selected_line = display let selected_line = display
.iter() .iter()
.position(|line| matches!(line,DisplayLine::Item(i) if *i==app.selected)) .position(|line| matches!(line,DisplayLine::Item { index, .. } if *index==app.selected))
.unwrap_or(0); .unwrap_or(0);
let visible = area.height as usize; let visible = area.height as usize;
if selected_line < app.scroll { if selected_line < app.scroll {
@@ -238,31 +291,37 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
))), ))),
DisplayLine::Header => lines.push(cells_line( DisplayLine::Header => lines.push(cells_line(
columns.iter().map(|c| c.heading.clone()).collect(), columns.iter().map(DisplayColumn::heading).collect(),
&widths, &widths,
Style::default() Style::default()
.fg(colors.canvas_heading) .fg(colors.canvas_heading)
.bg(colors.canvas) .bg(colors.canvas)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)), )),
DisplayLine::Item(index) => { DisplayLine::Item { index, section } => {
let item = &app.items[*index]; let item = &app.items[*index];
let style = if *index == app.selected { let style = Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
colors.foreground
})
.bg(colors.canvas);
let cells = columns
.iter()
.map(|column| column_value(column, item, section, &app.items, app))
.collect();
lines.push(cells_line_selected(
cells,
&columns,
&widths,
style,
(*index == app.selected).then_some(app.selected_column),
Style::default() Style::default()
.fg(colors.selection_foreground) .fg(colors.selection_foreground)
.bg(colors.selection) .bg(colors.selection)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD),
} else { ));
Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
colors.foreground
})
.bg(colors.canvas)
};
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
lines.push(cells_line(cells, &widths, style));
app.item_rows.push(( app.item_rows.push((
Rect::new(area.x, area.y + shown as u16, area.width, 1), Rect::new(area.x, area.y + shown as u16, area.width, 1),
item.id, item.id,
@@ -271,7 +330,13 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
DisplayLine::Aggregate(indices) => { DisplayLine::Aggregate(indices) => {
let cells = columns let cells = columns
.iter() .iter()
.map(|c| aggregate_value(c, indices, &app.items, app)) .map(|column| {
if column.percent {
String::new()
} else {
aggregate_value(&column.column, indices, &app.items, app)
}
})
.collect(); .collect();
lines.push(cells_line( lines.push(cells_line(
cells, cells,
@@ -293,21 +358,42 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
enum DisplayLine { enum DisplayLine {
Section(String), Section(String),
Header, Header,
Item(usize), Item { index: usize, section: Vec<usize> },
Aggregate(Vec<usize>), Aggregate(Vec<usize>),
} }
fn column_widths(columns: &[ViewColumn], total: u16) -> Vec<usize> { #[derive(Clone)]
struct DisplayColumn {
column: ViewColumn,
percent: bool,
base_index: usize,
}
impl DisplayColumn {
fn heading(&self) -> String {
if self.percent {
format!("{} %", self.column.heading)
} else {
self.column.heading.clone()
}
}
fn width(&self) -> u16 {
if self.percent { 10 } else { self.column.width }
}
}
fn column_widths(columns: &[DisplayColumn], total: u16) -> Vec<usize> {
let spacing = columns.len().saturating_sub(1) as u16; let spacing = columns.len().saturating_sub(1) as u16;
let available = total.saturating_sub(spacing).max(columns.len() as u16); let available = total.saturating_sub(spacing).max(columns.len() as u16);
let weights = columns let weights = columns
.iter() .iter()
.map(|c| c.width.max(1) as u32) .map(|column| column.width().max(1) as u32)
.sum::<u32>() .sum::<u32>()
.max(1); .max(1);
let mut widths = columns let mut widths = columns
.iter() .iter()
.map(|c| ((available as u32 * c.width.max(1) as u32 / weights).max(1)) as usize) .map(|column| ((available as u32 * column.width().max(1) as u32 / weights).max(1)) as usize)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let used = widths.iter().sum::<usize>(); let used = widths.iter().sum::<usize>();
if let Some(first) = widths.first_mut() { if let Some(first) = widths.first_mut() {
@@ -315,6 +401,31 @@ fn column_widths(columns: &[ViewColumn], total: u16) -> Vec<usize> {
} }
widths widths
} }
fn cells_line_selected(
values: Vec<String>,
columns: &[DisplayColumn],
widths: &[usize],
style: Style,
selected: Option<usize>,
selected_style: Style,
) -> Line<'static> {
let mut spans = Vec::with_capacity(values.len() * 2);
for (index, ((value, width), column)) in values.into_iter().zip(widths).zip(columns).enumerate()
{
if index > 0 {
spans.push(Span::styled(" ", style));
}
spans.push(Span::styled(
fit_aligned(&value, *width, &column.column.alignment),
if selected == Some(column.base_index) && !column.percent {
selected_style
} else {
style
},
));
}
Line::from(spans)
}
fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> { fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> {
let text = values let text = values
.into_iter() .into_iter()
@@ -336,7 +447,40 @@ fn fit(value: &str, width: usize) -> String {
} }
out out
} }
fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String { fn fit_aligned(value: &str, width: usize, alignment: &str) -> String {
let fitted = fit(value, width);
let trimmed = fitted.trim_end();
let padding = width.saturating_sub(trimmed.chars().count());
match alignment {
"right" => format!("{}{trimmed}", " ".repeat(padding)),
"center" => format!(
"{}{}{}",
" ".repeat(padding / 2),
trimmed,
" ".repeat(padding - padding / 2)
),
_ => fitted,
}
}
fn column_value(
display: &DisplayColumn,
item: &Item,
section: &[usize],
items: &[Item],
app: &App,
) -> String {
let column = &display.column;
if display.percent {
let total = section
.iter()
.filter_map(|index| column.numeric_value(&items[*index]))
.sum::<f64>();
return column
.numeric_value(item)
.filter(|_| total != 0.0)
.map(|value| format!("{:.2}%", value * 100.0 / total))
.unwrap_or_default();
}
match column.field.as_str() { match column.field.as_str() {
"item" => { "item" => {
let marker = if app.marked.contains(&item.id) { let marker = if app.marked.contains(&item.id) {
@@ -361,9 +505,15 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
" " " "
}, },
), ),
"value" => item "value" | "numeric" => column
.numeric_value .numeric_value(item)
.map(|v| format_number(v, app)) .map(|value| column.format_number(value))
.unwrap_or_default(),
"date" => column
.category
.as_deref()
.and_then(|category| item.date_value_for(category))
.map(|value| formatted_when(app, Some(value)))
.unwrap_or_default(), .unwrap_or_default(),
"done" => formatted_when(app, item.done_at.as_deref()), "done" => formatted_when(app, item.done_at.as_deref()),
"alarm" => formatted_when(app, item.alarm_at.as_deref()), "alarm" => formatted_when(app, item.alarm_at.as_deref()),
@@ -374,31 +524,8 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
} }
} }
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String { fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
if column.aggregate == Aggregate::None { let _ = app;
return String::new(); column.aggregate_value(indices.iter().map(|index| &items[*index]))
}
if column.aggregate == Aggregate::Count {
return format!("count {}", indices.len());
}
let values = indices
.iter()
.filter_map(|i| match column.field.as_str() {
"value" => items[*i].numeric_value,
"priority" => Some(items[*i].priority as f64),
_ => None,
})
.collect::<Vec<_>>();
if values.is_empty() {
return String::new();
}
let value = match column.aggregate {
Aggregate::Sum => values.iter().sum(),
Aggregate::Average => values.iter().sum::<f64>() / values.len() as f64,
Aggregate::Minimum => values.iter().copied().fold(f64::INFINITY, f64::min),
Aggregate::Maximum => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
Aggregate::None | Aggregate::Count => return String::new(),
};
format!("{} {}", column.aggregate, format_number(value, app))
} }
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) { fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
@@ -460,44 +587,15 @@ fn formatted_when(app: &App, value: Option<&str>) -> String {
) )
} }
fn format_number(value: f64, app: &App) -> String {
let negative = value.is_sign_negative();
let raw = format!("{:.2}", value.abs());
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, "00"));
let mut grouped = String::new();
for (index, c) in integer.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
grouped.push_str(&app.preferences.thousands_separator);
}
grouped.push(c);
}
let integer = grouped.chars().rev().collect::<String>();
format!(
"{}{}{}{}",
if negative { "-" } else { "" },
integer,
app.preferences.decimal_separator,
fraction
)
}
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let colors = palette(&app.preferences.theme); let colors = palette(&app.preferences.theme);
let totals = app let column = app
.items .current_view()
.iter() .columns
.filter_map(|i| i.numeric_value) .get(app.selected_column)
.collect::<Vec<_>>(); .map(|column| format!(" Cell: {}", column.heading))
let aggregate = if totals.is_empty() { .unwrap_or_default();
String::new() let right = format!("{} item(s){column}", app.items.len());
} else {
format!(
" Σ {} avg {}",
format_number(totals.iter().sum::<f64>(), app),
format_number(totals.iter().sum::<f64>() / totals.len() as f64, app)
)
};
let right = format!("{} item(s){}", app.items.len(), aggregate);
let pad = area let pad = area
.width .width
.saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16) .saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16)
@@ -631,7 +729,6 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Priority (1 highest)", "Priority (1 highest)",
"When (phrase or RFC3339)", "When (phrase or RFC3339)",
"Alarm (phrase or RFC3339)", "Alarm (phrase or RFC3339)",
"Numeric value",
"Recurrence (daily/weekly/monthly/every N days)", "Recurrence (daily/weekly/monthly/every N days)",
]; ];
let lines = names let lines = names
@@ -670,7 +767,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
popup( popup(
frame, frame,
center(frame.area(), 78, 18), center(frame.area(), 78, 16),
"Item Properties", "Item Properties",
Paragraph::new(lines), Paragraph::new(lines),
colors, colors,
@@ -689,7 +786,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Rule condition", "Rule condition",
"Rule action", "Rule action",
], ],
"Rule: category=Work and priority<=2 → assign:Important", "Condition files live; action fires on entry (numeric:Hours=7.5)",
), ),
FormKind::View(_) => ( FormKind::View(_) => (
"Live View Definition", "Live View Definition",
@@ -700,7 +797,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Boolean filter", "Boolean filter",
"Sort key", "Sort key",
"Show done (yes/no)", "Show done (yes/no)",
"Columns (field:width:heading:aggregate, …)", "Columns (numeric[Hours]:width:heading:aggregate:…)",
"Sections (heading|filter; …)", "Sections (heading|filter; …)",
], ],
"Filters: category=Work and (open or priority<=2)", "Filters: category=Work and (open or priority<=2)",
@@ -737,6 +834,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Afternoon time (HH:MM)", "Afternoon time (HH:MM)",
"Evening time (HH:MM)", "Evening time (HH:MM)",
"Note tab width (1-16)", "Note tab width (1-16)",
"Report headers and footers (yes/no)",
], ],
"These settings travel with the SQLite document.", "These settings travel with the SQLite document.",
), ),
@@ -927,7 +1025,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
Mode::Menu => { Mode::Menu => {
popup( popup(
frame, frame,
center(frame.area(), 58, 15), center(frame.area(), 64, 17),
"Rogue Agenda Menu", "Rogue Agenda Menu",
Paragraph::new(vec![ Paragraph::new(vec![
Line::from(" n New item"), Line::from(" n New item"),
@@ -937,6 +1035,8 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
Line::from(" p Preferences (user TOML)"), Line::from(" p Preferences (user TOML)"),
Line::from(" d Document settings (.agnd)"), Line::from(" d Document settings (.agnd)"),
Line::from(" x Macro Manager (Ctrl-G)"), Line::from(" x Macro Manager (Ctrl-G)"),
Line::from(" u Utilities Execute conditions/actions"),
Line::from(" f Inspect rule conflicts"),
Line::from(" b Back up document now"), Line::from(" b Back up document now"),
Line::from(" t Empty Trash permanently"), Line::from(" t Empty Trash permanently"),
Line::from(" q Quit"), Line::from(" q Quit"),
@@ -947,6 +1047,61 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
colors, colors,
); );
} }
Mode::Execute { selected } => {
let choices = [
"Current item",
"Marked items",
"Current section",
"Current view",
"Whole document",
];
let items = choices
.iter()
.map(|choice| ListItem::new(*choice))
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(*selected));
let area = center(frame.area(), 62, 9);
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(" Utilities Execute ")
.title_bottom(" Enter applies conditions/actions · Esc cancels ")
.style(Style::default().fg(Color::White).bg(colors.primary)),
)
.highlight_style(
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection),
),
area,
&mut state,
);
}
Mode::RuleConflicts => {
let conflicts = app.db.rule_conflicts().unwrap_or_default();
let lines = if conflicts.is_empty() {
vec![Line::from("No rule conflicts.")]
} else {
conflicts
.iter()
.map(|conflict| {
Line::from(format!(
"#{} {}{}",
conflict.item_id, conflict.item_text, conflict.message
))
})
.collect()
};
popup(
frame,
center(frame.area(), 90, (lines.len() as u16 + 4).clamp(7, 22)),
"Rule Conflicts (any key closes)",
Paragraph::new(lines).wrap(Wrap { trim: false }),
colors,
);
}
Mode::Confirm { prompt, .. } => { Mode::Confirm { prompt, .. } => {
popup( popup(
frame, frame,
@@ -1346,7 +1501,7 @@ mod tests {
"priority", "priority",
true, true,
"open", "open",
"item:70:Action:none,value:30:Cost:sum", "item:70:Action:none,value:30:Cost:sum::2:.:comma:minus:percent:right",
"Urgent work|priority=1", "Urgent work|priority=1",
) )
.unwrap(); .unwrap();
@@ -1372,7 +1527,8 @@ mod tests {
assert!(screen.contains("Urgent work")); assert!(screen.contains("Urgent work"));
assert!(screen.contains("Action")); assert!(screen.contains("Action"));
assert!(screen.contains("Cost")); assert!(screen.contains("Cost"));
assert!(screen.contains("sum 75.00")); assert!(screen.contains("Total 75.00"));
assert!(screen.contains("100.00%"));
} }
#[test] #[test]
@@ -1527,7 +1683,6 @@ mod tests {
.collect::<String>(); .collect::<String>();
assert!(screen.contains("> Configured display item")); assert!(screen.contains("> Configured display item"));
assert!(screen.contains("08/17/2026 3:30pm")); assert!(screen.contains("08/17/2026 3:30pm"));
assert!(screen.contains("1.234,50"));
assert!(!screen.contains("Cat Mgr")); assert!(!screen.contains("Cat Mgr"));
app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)) app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))

2
~/.local/.crates.toml Normal file
View File

@@ -0,0 +1,2 @@
[v1]
"rogue-agenda 0.1.0 (path+file:///home/gb/Projects/RogueAgenda)" = ["rogue-agenda"]

1
~/.local/.crates2.json Normal file
View File

@@ -0,0 +1 @@
{"installs":{"rogue-agenda 0.1.0 (path+file:///home/gb/Projects/RogueAgenda)":{"version_req":null,"bins":["rogue-agenda"],"features":[],"all_features":false,"no_default_features":false,"profile":"release","target":"aarch64-unknown-linux-gnu","rustc":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: aarch64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 22.1.6\n"}}}

BIN
~/.local/bin/rogue-agenda Executable file

Binary file not shown.