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.
- Calls, meetings, and follow-ups under an Activities hierarchy.
- Open, waiting, and resolved issue states.
- Numeric expense records with a summed Amount column.
- Category-scoped expense values with a summed Amount column and per-section
`% of total` companion.
- A sectioned status report divided into urgent and routine material.
- Filing rules that collect priority-one work into the status report and detect
follow-up wording.
@@ -68,7 +69,8 @@ CRM, client service, fundraising, vendor management, or case tracking.
Study Deck is inspired by the idea of using Agenda items and notes as a learning
system. The item text is a question, its multiline note is the answer, `When` is
the next review date, and the numeric value is a visible learning-cycle number.
the next review date, and the Cycle numeric category holds its learning-cycle
number.
It provides:
@@ -119,7 +121,7 @@ It provides:
- Course categories for Breakfast, Main Dishes, Desserts, and Drinks.
- Cuisine, dietary, ingredient, Quick, and Favorites classifications.
- A numeric Servings value shown alongside each recipe.
- An independent Servings numeric-category value shown alongside each recipe.
- Recipe Box, Favorites, Main Dishes, Quick Meals, Vegetarian, and sectioned By
Course views.
- Five small original recipes covering different courses and dietary tags.
@@ -135,7 +137,8 @@ ingredients as children of Ingredients to turn the preset into a deeper index.
## `rides`
Bicycle Log demonstrates numeric reporting and several overlapping category
dimensions. Ride distance is stored as the item's numeric value in kilometres.
dimensions. Ride distance is stored under the Distance numeric category in
kilometres.
It provides:
@@ -149,8 +152,9 @@ It provides:
- Rules that recognize ride and maintenance wording.
- `logride` and `servicereminder` capture macros.
Use `F6` on a newly captured ride to record its numeric distance and date, then
assign bicycle, ride type, route, and weather with `F3`.
Assign Distance with `F3`, select the Distance cell with Left/Right, and press
`F2` to enter kilometres. Use `F6` for the ride date, then assign bicycle, ride
type, route, and weather with `F3`.
## `people`
@@ -166,7 +170,7 @@ It provides:
hiring activity.
- People Directory, Upcoming Reviews, Goals, Development, Candidates, and
Achievements views.
- A numeric Progress example on a dated goal.
- A category-scoped Progress value displayed on dated goals.
- Filing rules for `Person:`, `Review:`, and `Goal:` capture conventions.
- `addperson` and `addgoal` prompt-driven macros.

View File

@@ -70,7 +70,8 @@ Research URLs:
- [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL,
transactions, integrity-friendly soft deletion, and automatic checkpoints.
- [x] Items with free text, note, priority, When, Done, alarm, numeric value,
- [x] Items with free text, note, priority, When, Done, alarm, independent
category-scoped numeric/date values,
recurring schedule, creation/update timestamps, manual order, and prerequisite
links. Completion advances recurring work and carries alarms and filing forward.
- [x] Categories with hierarchy, inheritance in queries, kinds (standard, date,
@@ -136,28 +137,21 @@ Research URLs:
## Supported with a modern interpretation
- [x] Numeric calculations currently use one optional `numeric_value` per item and
expose five named aggregate modes: `sum`, `avg`, `count`, `min`, and `max`
(`model/enums.rs` `Aggregate`, `ui.rs` `aggregate_value`). Sum, average, minimum,
and maximum operate on the populated values in a section. Count currently counts
every item in the section, whereas Lotus Count counted populated entries in the
numeric column. The names therefore align with Lotus, but the data model,
category scoping, Count semantics, formatting, and presentation do not yet have
full parity.
- [x] Conditions / actions: each `CategoryRule` pairs a parsed condition
expression (`filter.rs` `Predicate`: text/note containment, category membership,
priority and numeric-value comparison, done / has-note / has-date / due-within /
overdue / alarm / recurring) with a deterministic action (`RuleActionKind`:
assign, exclude, priority, value, when, alarm, repeat, done). This is a useful
modern predicate-to-mutation engine, but it is not yet a faithful implementation
of Lotus conditions and actions. Re-evaluation on item change is currently tied
to automatic filing, and a conditional assignment is not withdrawn when its
predicate becomes false. `Done` overlaps one Lotus Special action; rule-driven
export and discard are missing.
- [x] Numeric calculations use independent itemnumeric-category values and
category-bound view columns. Count, Total, Average, Minimum, and Maximum follow
populated-cell semantics per section; formatting and `% of total` are column
properties. The retired `items.numeric_value` column remains only as a migration
bridge for version-4 documents and older API callers.
- [x] Conditions and actions follow separate Lotus lifecycles. Conditions maintain
reversible conditional assignments to their own categories; actions fire once
on source-category entry and make explicit persistent changes. Category-scoped
date/numeric ranges and actions, removal, Done/export/discard Special actions,
scoped Utilities Execute, and inspectable non-convergence state are supported.
- [x] Printing: view-aware Markdown and styled HTML reports are the portable
print/preview foundation, alongside CSV and JSON data exports. Markdown and HTML
currently omit aggregate rows, percentage-of-total columns, numeric formatting,
and other report-layout fidelity listed below.
include live-view aggregate rows, percentage-of-total columns, portable numeric
formatting, relative widths/alignment, print CSS, metadata, and optional headers
and footers.
- [x] Backups: SQLite durability is supplemented by explicit/manual and
backup-on-open `.agnd.bak` snapshots after a full WAL checkpoint. Ordinary
filesystem backup tools also work on `.agnd` files.
@@ -174,60 +168,60 @@ file-format compatibility are not parity goals.
### Numeric data and calculations
- [ ] Store a separate value for every itemnumeric-category assignment instead of
the current single `items.numeric_value` scalar. Preserve explicit, automatic,
and conditional assignment provenance while doing so.
- [ ] Bind numeric view columns to a specific numeric category so several values,
- [x] Store a separate value for every itemnumeric-category assignment rather
than relying on the legacy `items.numeric_value` scalar. Preserve explicit,
automatic, and conditional assignment provenance while doing so.
- [x] Bind numeric view columns to a specific numeric category so several values,
such as Hours, Rate, Cost, and Budget, can coexist on one item and be displayed,
filtered, sorted, edited, and aggregated independently.
- [ ] Scope numeric conditions and numeric actions to a chosen numeric category,
- [x] Scope numeric conditions and numeric actions to a chosen numeric category,
with inside/outside minimum and maximum ranges and assignment removal.
- [ ] Match Lotus aggregate semantics per numeric column and per section: Count
- [x] Match Lotus aggregate semantics per numeric column and per section: Count
populated numeric entries rather than rows, and calculate Total, Average,
Minimum, and Maximum from that column's values only.
- [ ] Add per-numeric-column display properties: label or currency symbol, decimal
- [x] Add per-numeric-column display properties: label or currency symbol, decimal
places, thousands separator, negative-number style, and full-precision storage
independent of rounded display.
- [ ] Implement `Display % of total` as a derived, non-editable companion column
- [x] Implement `Display % of total` as a derived, non-editable companion column
calculated within each section.
- [ ] Make macro highlight context cell-aware. `#HIGHLIGHT_VALUE` must expose the
- [x] Make macro highlight context cell-aware. `#HIGHLIGHT_VALUE` must expose the
highlighted numeric, date, text, category, or item value so Agenda-style macro
arithmetic can operate on document data rather than only on item text.
### Conditions and actions
- [ ] Separate Lotus-style conditions from actions. A condition creates a live
- [x] Separate Lotus-style conditions from actions. A condition creates a live
conditional assignment to its own category; an action creates an explicit,
persistent assignment or value change after an item enters its source category.
- [ ] Withdraw conditional assignments automatically when their Text, Assignment,
- [x] Withdraw conditional assignments automatically when their Text, Assignment,
Date, or Numeric condition becomes false, while preserving explicit assignments.
- [ ] Re-evaluate affected rules whenever their inputs change, independently of the
- [x] Re-evaluate affected rules whenever their inputs change, independently of the
automatic text-filing preference.
- [ ] Implement category-scoped Assignment, Date, and Numeric conditions with the
- [x] Implement category-scoped Assignment, Date, and Numeric conditions with the
original positive/negative and inside/outside-range behavior.
- [ ] Implement Assignment, Date, and Numeric actions with explicit persistence,
- [x] Implement Assignment, Date, and Numeric actions with explicit persistence,
including assignment removal and category-specific date and numeric values.
- [ ] Complete Special actions: designate as done, export to a selected structured
destination, and discard. `Done` exists today; export and discard do not.
- [ ] Add a user-facing re-evaluation command equivalent to Utilities `Execute`,
- [x] Complete Special actions: designate as done, export to a selected structured
destination, and discard.
- [x] Add a user-facing re-evaluation command equivalent to Utilities `Execute`,
scoped to the current item, marked items, section, view, or whole document. This
means applying rules to existing data, not executing an external process.
- [ ] Surface circular or conflicting rules as inspectable document state instead
- [x] Surface circular or conflicting rules as inspectable document state instead
of only returning a non-convergence error.
### Markdown, HTML, and modern printing
- [ ] Emit calculation-label footer rows and results for every configured aggregate
- [x] Emit calculation-label footer rows and results for every configured aggregate
in every Markdown and HTML section, using the same values as the live view.
- [ ] Emit `% of total` companion columns when enabled and keep percentage values
- [x] Emit `% of total` companion columns when enabled and keep percentage values
consistent between the TUI, Markdown, and HTML.
- [ ] Apply each numeric column's label/currency, decimal, separator, and negative
- [x] Apply each numeric column's label/currency, decimal, separator, and negative
formatting consistently in Markdown and HTML reports.
- [ ] Preserve configured column alignment and relative widths in HTML and use
- [x] Preserve configured column alignment and relative widths in HTML and use
appropriate Markdown alignment markers where Markdown can represent them.
- [ ] Add print-focused HTML styling: repeated table headers, sensible page breaks,
- [x] Add print-focused HTML styling: repeated table headers, sensible page breaks,
section continuity, note wrapping, and optional document/view headers and footers.
- [ ] Include report metadata needed to understand an export: document description,
- [x] Include report metadata needed to understand an export: document description,
view name, active filters, and generation time, without changing data-only CSV
and JSON exports into presentation formats.
@@ -244,11 +238,6 @@ file-format compatibility are not parity goals.
## Near-term plan
1. Implement per-numeric-category values and category-bound numeric columns.
2. Correct conditional-assignment lifecycle and separate conditions from explicit
actions.
3. Complete aggregate, percentage, numeric-formatting, Markdown, and HTML report
parity.
4. Add desktop alarm integration and in-application snooze controls.
5. Add encrypted documents and secure unlock handling.
6. Add property-based tests for complex hierarchy, rule, and numeric combinations.
1. Add desktop alarm integration and in-application snooze controls.
2. Add encrypted documents and secure unlock handling.
3. Add property-based tests for complex hierarchy, rule, and numeric combinations.

View File

@@ -14,8 +14,9 @@ database with the `.agnd` extension.
## Features
- Free-form items with multiline notes, priority, due dates, done dates, alarms,
numeric values, recurring schedules, and prerequisites. Completing a recurring
item creates its next dated occurrence and carries its alarm and filing forward.
independent date and numeric category values, recurring schedules, and
prerequisites. Completing a recurring item creates its next dated occurrence
and carries its alarm and filing forward.
- Hierarchical categories with inherited membership and explicit many-to-many
assignment and negative exclusions: an item exists once but can appear in many
contexts.
@@ -23,14 +24,15 @@ database with the `.agnd` extension.
`today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates.
- Live views for all items, tasks, upcoming work, completed work, and a datebook.
- Full-text search plus composable Boolean filters over text, notes, categories,
dates, done state, priority, numeric value, alarms, recurrence, and relative due
windows.
dates, done state, priority, category-scoped numeric values, alarms, recurrence,
and relative due windows.
- Agenda-style View Manager and Category Manager screens with editable live
view definitions, hierarchy, category kinds, match phrases, and exclusivity.
- Persistent per-view columns, custom headings and widths, numeric aggregates,
filtered multi-section layouts, collapsed sections, and view reordering.
- User-authored category rules with a live match preview and deterministic actions
for assignments, exclusions, priority, value, dates, and completion.
- Lotus-style live conditions and persistent actions with match previews,
reversible conditional assignments, category-scoped date/numeric ranges and
values, assignment removal, completion, structured export, and discard.
- Marking and bulk completion, soft deletion with Trash recovery, sorting, and
dependency visibility.
- View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar
@@ -130,13 +132,14 @@ or build failure stops the gate.
| Key | Action |
| --- | --- |
| Arrows or `h j k l` | Move selection / scroll |
| Up/Down or `j`/`k` | Move between item rows |
| Left/Right | Move between cells/columns |
| `Insert` or `n` | Add a free-form item |
| `F2` or `e` | Edit the selected item |
| `F2` or `e` | Edit the selected item or bound value cell |
| `F3` or `c` | Assign categories |
| `F4` or `d` | Toggle done |
| `F5` | Edit the item's note |
| `F6` or `p` | Edit item properties |
| `F6` or `p` | Edit priority, When, alarm, and recurrence |
| `F7` or `Space` | Mark for bulk operations |
| `F8` or `v` | View Manager |
| `F9` | Category Manager |
@@ -167,7 +170,8 @@ as typed TOML at `~/.config/rogue-agenda/preferences.toml`. The screen controls:
- carriage-return and item markers
- autosave checkpoint interval and destructive-action confirmations
- ISO, US, European, or long date display and 12/24-hour time
- decimal and thousands separators
- decimal and thousands separators for legacy unbound numeric displays; bound
numeric columns carry their own portable report formatting
Greenscreen, Nord, and Catppuccin Mocha use dedicated selected-row
foreground/background pairs with at least 7:1 relative-luminance contrast.
@@ -184,7 +188,8 @@ retention (`on-demand`, `on-close`, `end-of-day`, or `immediate`), completed-ite
policy, automatic filing, numeric date order, first day of the week, default and
named times, and note-editor tab width. `this week` and `next week` honor the
chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured
times.
times, plus whether Markdown/HTML reports include document/view headers and
footers.
Enumerated preferences and document settings use choice popovers, so values such
as themes, yes/no switches, policies, date formats, and week boundaries cannot be
@@ -258,7 +263,9 @@ Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
`Ctrl-Up`/`Ctrl-Down` to reorder it, or `Delete` to remove it.
Boolean filters use `and`, `or`, `not`, and parentheses. Available predicates
include `text~rust`, `note~budget`, `category=Work`, `priority<=2`, `value>100`,
include `text~rust`, `note~budget`, `category=Work`, `priority<=2`,
`numeric:Hours>7`, `numeric:Cost inside 100..500`,
`date:"Review Date" outside 2026-08-01..2026-08-31`,
`done`, `open`, `dated`, `undated`, `due<=7d`, `overdue`, `has-note`, `alarm`,
`recurring`, and `nonrecurring`.
For example:
@@ -267,15 +274,32 @@ For example:
category=Work and open and (priority<=2 or due<=3d)
```
Columns use `field:width:heading:aggregate`, separated by commas. Fields are
`item`, `categories`, `when`, `priority`, `note`, `value`, `done`, `alarm`,
`recurrence`, `created`, and `updated`; aggregates are `none`, `sum`, `avg`,
`count`, `min`, or `max`.
Columns use `field:width:heading:aggregate`, separated by commas. Ordinary fields
are `item`, `categories`, `when`, `priority`, `note`, `done`, `alarm`,
`recurrence`, `created`, and `updated`. Category values use `numeric[Category]`
or `date[Category]`; aggregates are `none`, `sum`, `avg`, `count`, `min`, or
`max`. A view may sort by a bound field such as `numeric[Hours]`.
```text
item:55:Action:none,when:25:Due:none,value:20:Cost:sum
item:55:Action:none,when:25:Due:none,numeric[Cost]:20:Cost:sum
```
Numeric columns accept optional formatting fields after the aggregate:
`label:decimals:decimal-separator:thousands:negative:percent:alignment`.
Decimal separator is `dot` or `comma`; thousands is `comma`, `dot`, `space`, or
`none`; negative style is `minus`,
`parentheses`, or `trailing`; use `percent` to add a non-editable `% of total`
companion column.
```text
numeric[Cost]:20:Cost:sum:$:2:dot:comma:parentheses:percent:right
```
Count counts populated numeric cells, not section rows. Total, Average, Minimum,
and Maximum use only the bound category's populated values. Left/Right selects a
cell; `F2` edits a bound numeric or date value. `#HIGHLIGHT_VALUE` and
`#HIGHLIGHT_TYPE` follow that cell for macro arithmetic.
Sections use `heading|filter`, separated by semicolons. Add `|collapsed` to make
a section initially collapsed.
@@ -286,14 +310,22 @@ Urgent|priority=1;Upcoming|due<=7d;Someday|undated|collapsed
## Automatic category rules
Open the Category Manager with `F9`, then create or edit a category. In addition
to simple match phrases, each category can have a Boolean rule condition and an
action. The form previews how many existing items match before saving.
to simple match phrases, each category can have a Boolean condition and an
action. The form previews how many existing items match before saving. A true
condition creates a live conditional assignment to the category being edited;
that assignment is withdrawn when the condition becomes false, without removing
an explicit assignment. This lifecycle is independent of automatic text filing.
Actions use `action:value`: `assign:Category`, `exclude:Category`, `priority:1`,
`value:125.50`, `when:tomorrow at 3pm`, `alarm:tomorrow at 2pm`,
`repeat:weekdays`, or `done:true`. `assign:` with no value assigns the category
being edited. Rules are reapplied to existing information and whenever an item
changes.
An action fires once when an item enters the category and creates a persistent
change. Actions use `action:value`: `assign:Category`, `exclude:Category`,
`remove:Category`, `priority:1`, `numeric:Hours=7.5`,
`date:Review Date=tomorrow`, `when:tomorrow at 3pm`,
`alarm:tomorrow at 2pm`, `repeat:weekdays`, `done:true`,
`export:jsonl|archive.jsonl`, or `discard:true`.
Use `F10`, then `u`, for Utilities Execute. It reapplies conditions and actions
to the current item, marked items, current section, current view, or whole
document. `F10`, then `f`, shows circular/non-converging rule conflicts.
## Recurring work
@@ -301,7 +333,8 @@ Open item properties with `F6` and set a recurrence to `daily`, `weekdays`,
`weekly`, `monthly`, `yearly`, or a rule such as `every 2 weeks`. The item must
have a When date. Marking it done preserves the completed occurrence and creates
the next open occurrence. Rogue Agenda shifts its alarm by the same interval and
copies its note, priority, numeric value, recurrence, and explicit category
copies its note, priority, recurrence, explicit category assignments, and
category-scoped date/numeric values
assignments. iCalendar import and export maps these schedules to `RRULE`.
## Design notes

View File

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

View File

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

1232
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 {
let mut output = format!("# Rogue Agenda — {}\n\n", view.name);
pub(super) fn render_markdown(
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) {
if !heading.is_empty() {
output.push_str(&format!("## {}\n\n", markdown(&heading)));
}
output.push('|');
for column in &view.columns {
output.push_str(&format!(" {} |", markdown(&column.heading)));
for column in report_columns(view) {
output.push_str(&format!(" {} |", markdown(&column.heading())));
}
output.push('\n');
output.push('|');
for _ in &view.columns {
output.push_str(" --- |");
for column in report_columns(view) {
output.push_str(match column.alignment() {
"right" => " ---: |",
"center" => " :---: |",
_ => " :--- |",
});
}
output.push('\n');
for item in group {
for item in &group {
output.push('|');
for column in &view.columns {
output.push_str(&format!(
" {} |",
markdown(&report_value(&column.field, item))
));
for column in report_columns(view) {
output.push_str(&format!(" {} |", markdown(&column.value(item, &group))));
}
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');
}
@@ -128,37 +158,91 @@ pub(super) fn render_markdown(view: &ViewDef, items: &[Item]) -> String {
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!(
"<!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)
);
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) {
output.push_str("<section>");
if !heading.is_empty() {
output.push_str(&format!("<h2>{}</h2>", html(&heading)));
}
output.push_str("<table><thead><tr>");
for column in &view.columns {
output.push_str(&format!("<th>{}</th>", html(&column.heading)));
output.push_str("<table><colgroup>");
for column in report_columns(view) {
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>");
for item in group {
for item in &group {
output.push_str("<tr>");
for column in &view.columns {
for column in report_columns(view) {
output.push_str(&format!(
"<td>{}</td>",
html(&report_value(&column.field, item)).replace('\n', "<br>")
"<td style=\"text-align:{}\">{}</td>",
column.alignment(),
html(&column.value(item, &group)).replace('\n', "<br>")
));
}
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
}
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>)> {
if view.sections.is_empty() {
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()
}
fn report_value(field: &str, item: &Item) -> String {
match field {
"item" => item.text.clone(),
"categories" => item.category_names(),
"when" => item.when_at.clone().unwrap_or_default(),
"priority" => item.priority.to_string(),
"note" => item.note.clone(),
"value" => item
.numeric_value
.map(|value| value.to_string())
.unwrap_or_default(),
"done" => item.done_at.clone().unwrap_or_default(),
"alarm" => item.alarm_at.clone().unwrap_or_default(),
"recurrence" => item.recurrence.clone(),
"created" => item.created_at.clone(),
"updated" => item.updated_at.clone(),
_ => String::new(),
enum ReportColumn<'a> {
Value(&'a crate::model::ViewColumn),
Percent(&'a crate::model::ViewColumn),
}
impl ReportColumn<'_> {
fn heading(&self) -> String {
match self {
Self::Value(column) => column.heading.clone(),
Self::Percent(column) => format!("{} %", column.heading),
}
}
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 {

View File

@@ -5,14 +5,16 @@ use chrono::{Duration, Local, TimeZone};
use clap::ValueEnum;
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::{
filter,
macro_lang::{MacroRuntime, macro_name, parse_key_binding},
model::MacroDef,
};
const PRESET_VERSION: &str = "1";
const PRESET_VERSION: &str = "2";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Preset {
@@ -282,14 +284,22 @@ impl Database {
let view_id = tx.last_insert_rowid();
for (column_order, column) in parse_columns_spec(view.columns)?.iter().enumerate() {
tx.execute(
"INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order)
VALUES(?1,?2,?3,?4,?5,?6)",
"INSERT INTO view_columns(view_id,field,category_id,heading,width,aggregate,number_label,decimals,decimal_separator,thousands_separator,negative_style,percent_total,alignment,sort_order)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
params![
view_id,
column.field,
column.category.as_deref().map(|name| category_ids[name]),
column.heading,
column.width,
column.aggregate.as_str(),
column.number_label,
column.decimals,
column.decimal_separator,
column.thousands_separator,
column.negative_style,
column.percent_total as i64,
column.alignment,
column_order as i64
],
)?;
@@ -344,15 +354,14 @@ impl Database {
.transpose()?;
let done_at = item.done.then(|| now.clone());
tx.execute(
"INSERT INTO items(text,note,priority,when_at,done_at,numeric_value,recurrence,created_at,updated_at,sort_order)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,?9)",
"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,?7,?8)",
params![
item.text,
item.note,
item.priority,
when_at,
done_at,
item.numeric_value,
item.recurrence,
now,
order as i64
@@ -365,6 +374,26 @@ impl Database {
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 [
@@ -420,7 +449,38 @@ fn validate_spec(spec: &PresetSpec) -> Result<()> {
bail!("preset has duplicate view {:?}", view.name);
}
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)?;
}
@@ -522,7 +582,7 @@ fn accounts() -> PresetSpec {
ViewSpec::new("Expenses")
.category("Expenses")
.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")
.category("Status Report")
.sort("priority")
@@ -580,8 +640,8 @@ fn accounts() -> PresetSpec {
],
views,
rules: vec![
rule("Status Report", "priority=1 and open", "assign:"),
rule("Follow-ups", "text~follow-up", "assign:"),
rule("Status Report", "priority=1 and open", ""),
rule("Follow-ups", "text~follow-up", ""),
],
macros: vec![
agenda_macro(
@@ -649,7 +709,7 @@ fn study() -> PresetSpec {
.sort("when")
.hide_done()
.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")
.category("New")
.sort("manual")
@@ -658,7 +718,7 @@ fn study() -> PresetSpec {
ViewSpec::new("All Cards")
.category("Cards")
.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")
.category("Cards")
.columns("item:52:Question,note:42:Answer,categories:28:Deck")
@@ -683,7 +743,7 @@ fn study() -> PresetSpec {
category("Cycle", None, "numeric", "", false),
],
views,
rules: vec![rule("Cards", "text~q:", "assign:")],
rules: vec![rule("Cards", "text~q:", "")],
macros: vec![
agenda_macro(
"capturecard",
@@ -837,15 +897,15 @@ fn planner() -> PresetSpec {
),
category("Ideas", None, "standard", "idea:,maybe", false),
category("When", None, "date", "", false),
category("Priority", None, "numeric", "", false),
category("Priority", None, "standard", "", false),
],
views,
rules: vec![
rule("Next Actions", "priority<=2 and open", "assign:"),
rule("Next Actions", "priority<=2 and open", ""),
rule(
"Scheduled Activity",
"dated and (category=Calls or category=Meetings)",
"assign:",
"",
),
],
macros: vec![
@@ -912,22 +972,22 @@ fn recipes() -> PresetSpec {
let mut views = vec![
ViewSpec::new("Recipe Box")
.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")
.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")
.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")
.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")
.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")
.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"),
ViewSpec::new("All Items").sort("updated"),
];
@@ -960,8 +1020,8 @@ fn recipes() -> PresetSpec {
],
views,
rules: vec![
rule("Recipes", "text~recipe:", "assign:"),
rule("Quick", "note~minutes", "assign:"),
rule("Recipes", "text~recipe:", ""),
rule("Quick", "note~minutes", ""),
],
macros: vec![
agenda_macro(
@@ -1013,19 +1073,19 @@ fn rides() -> PresetSpec {
ViewSpec::new("Ride Log")
.category("Rides")
.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")
.category("Training")
.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")
.category("Rides")
.filter("value>=50")
.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")
.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'"),
ViewSpec::new("Maintenance")
.category("Maintenance")
@@ -1115,8 +1175,8 @@ fn rides() -> PresetSpec {
],
views,
rules: vec![
rule("Rides", "text~ride:", "assign:"),
rule("Maintenance", "text~service or text~replace", "assign:"),
rule("Rides", "text~ride:", ""),
rule("Maintenance", "text~service or text~replace", ""),
],
macros: vec![
agenda_macro(
@@ -1213,7 +1273,7 @@ fn people() -> PresetSpec {
.category("Goals")
.sort("priority")
.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")
.category("Development")
.sort("when")
@@ -1254,9 +1314,9 @@ fn people() -> PresetSpec {
],
views,
rules: vec![
rule("People Records", "text~person:", "assign:"),
rule("Reviews", "text~review:", "assign:"),
rule("Goals", "text~goal:", "assign:"),
rule("People Records", "text~person:", ""),
rule("Reviews", "text~review:", ""),
rule("Goals", "text~goal:", ""),
],
macros: vec![
agenda_macro(
@@ -1346,6 +1406,34 @@ mod tests {
assert!(database.categories().unwrap().len() >= 10);
assert!(database.views().unwrap().len() >= 7);
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!(
database
.conn

View File

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

View File

@@ -1,4 +1,4 @@
use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Local, TimeZone};
use crate::model::Item;
@@ -19,6 +19,10 @@ pub enum Predicate {
Category(String),
Priority(Compare, i64),
Value(Compare, f64),
NumericValue(String, Compare, f64),
NumericRange(String, f64, f64, bool),
DateValue(String, Compare, String),
DateRange(String, String, String, bool),
Done(bool),
HasNote,
HasDate(bool),
@@ -75,6 +79,20 @@ fn predicate(p: &Predicate, item: &Item) -> bool {
.any(|c| c.name.eq_ignore_ascii_case(s)),
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::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::HasNote => !item.note.trim().is_empty(),
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)]
enum Token {
Word(String),
@@ -301,6 +330,34 @@ impl Parser {
let op = self.required_op()?;
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" => {
let _ = self.optional_op();
let raw = self.word()?.to_lowercase();
@@ -329,6 +386,19 @@ impl Parser {
_ => 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> {
if let Some(Token::Op(op)) = self.tokens.get(self.at).cloned() {
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)]
mod tests {
use super::*;
@@ -365,6 +446,7 @@ mod tests {
created_at: String::new(),
updated_at: String::new(),
discarded: false,
values: vec![],
categories: vec![Category {
id: 1,
name: "Work".into(),

View File

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

View File

@@ -66,15 +66,34 @@ string_enum!(Aggregate {
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 {
None => "none",
Assign => "assign",
Exclude => "exclude",
Remove => "remove",
Priority => "priority",
Value => "value",
Numeric => "numeric",
Date => "date",
When => "when",
Alarm => "alarm",
Repeat => "repeat",
Done => "done",
Export => "export",
Discard => "discard",
});
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)]
pub struct Item {
@@ -15,6 +25,7 @@ pub struct Item {
pub updated_at: String,
pub discarded: bool,
pub categories: Vec<Category>,
pub values: Vec<CategoryValue>,
}
impl Item {
@@ -25,6 +36,34 @@ impl Item {
.collect::<Vec<_>>()
.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)]

View File

@@ -14,6 +14,7 @@ pub struct DocumentSettings {
pub afternoon_time: String,
pub evening_time: String,
pub note_tab_width: u8,
pub report_headers: bool,
}
impl Default for DocumentSettings {
@@ -31,6 +32,7 @@ impl Default for DocumentSettings {
afternoon_time: "13:00".into(),
evening_time: "18:00".into(),
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)]
pub struct ViewDef {
@@ -6,7 +6,7 @@ pub struct ViewDef {
pub name: String,
pub kind: ViewKind,
pub filter_value: String,
pub sort_key: SortKey,
pub sort_key: String,
pub show_done: bool,
pub filter_expr: String,
pub columns: Vec<ViewColumn>,
@@ -16,9 +16,17 @@ pub struct ViewDef {
#[derive(Debug, Clone, PartialEq)]
pub struct ViewColumn {
pub field: String,
pub category: Option<String>,
pub heading: String,
pub width: u16,
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)]
@@ -35,8 +43,31 @@ impl ViewDef {
.iter()
.map(|column| {
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<_>>()
@@ -58,3 +89,121 @@ impl ViewDef {
.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))
}
}

367
src/ui.rs
View File

@@ -146,6 +146,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
area,
);
app.item_rows.clear();
app.item_columns.clear();
if app.items.is_empty() {
frame.render_widget(
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);
return;
}
let mut columns = app.current_view().columns.clone();
if area.width < 70 && columns.len() > 2 {
let item = columns
let mut base_columns = app
.current_view()
.columns
.iter()
.find(|c| c.field == "item")
.cloned()
.unwrap_or_else(|| columns[0].clone());
let detail = columns
.enumerate()
.collect::<Vec<_>>();
if area.width < 70 && base_columns.len() > 2 {
let item = base_columns
.iter()
.find(|c| c.field == "when")
.find(|(_, column)| column.field == "item")
.cloned()
.or_else(|| columns.iter().find(|c| c.field != item.field).cloned());
columns = vec![item];
.unwrap_or_else(|| base_columns[0].clone());
let detail = base_columns
.iter()
.find(|(_, column)| column.field == "when")
.cloned()
.or_else(|| {
base_columns
.iter()
.find(|(_, column)| column.field != item.1.field)
.cloned()
});
base_columns = vec![item];
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 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 sections = app.current_view().sections.clone();
if sections.is_empty() {
display.push(DisplayLine::Header);
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()));
}
} else {
@@ -209,16 +256,22 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.map(|(i, _)| i)
.collect::<Vec<_>>();
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));
}
}
}
let selected_line = display
.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);
let visible = area.height as usize;
if selected_line < app.scroll {
@@ -238,31 +291,37 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.add_modifier(Modifier::BOLD),
))),
DisplayLine::Header => lines.push(cells_line(
columns.iter().map(|c| c.heading.clone()).collect(),
columns.iter().map(DisplayColumn::heading).collect(),
&widths,
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
)),
DisplayLine::Item(index) => {
DisplayLine::Item { index, section } => {
let item = &app.items[*index];
let style = if *index == app.selected {
Style::default()
.fg(colors.selection_foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
let style = 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));
.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()
.fg(colors.selection_foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD),
));
app.item_rows.push((
Rect::new(area.x, area.y + shown as u16, area.width, 1),
item.id,
@@ -271,7 +330,13 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
DisplayLine::Aggregate(indices) => {
let cells = columns
.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();
lines.push(cells_line(
cells,
@@ -293,21 +358,42 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
enum DisplayLine {
Section(String),
Header,
Item(usize),
Item { index: usize, section: 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 available = total.saturating_sub(spacing).max(columns.len() as u16);
let weights = columns
.iter()
.map(|c| c.width.max(1) as u32)
.map(|column| column.width().max(1) as u32)
.sum::<u32>()
.max(1);
let mut widths = columns
.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<_>>();
let used = widths.iter().sum::<usize>();
if let Some(first) = widths.first_mut() {
@@ -315,6 +401,31 @@ fn column_widths(columns: &[ViewColumn], total: u16) -> Vec<usize> {
}
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> {
let text = values
.into_iter()
@@ -336,7 +447,40 @@ fn fit(value: &str, width: usize) -> String {
}
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() {
"item" => {
let marker = if app.marked.contains(&item.id) {
@@ -361,9 +505,15 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
" "
},
),
"value" => item
.numeric_value
.map(|v| format_number(v, app))
"value" | "numeric" => column
.numeric_value(item)
.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(),
"done" => formatted_when(app, item.done_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 {
if column.aggregate == Aggregate::None {
return String::new();
}
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))
let _ = app;
column.aggregate_value(indices.iter().map(|index| &items[*index]))
}
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) {
let colors = palette(&app.preferences.theme);
let totals = app
.items
.iter()
.filter_map(|i| i.numeric_value)
.collect::<Vec<_>>();
let aggregate = if totals.is_empty() {
String::new()
} 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 column = app
.current_view()
.columns
.get(app.selected_column)
.map(|column| format!(" Cell: {}", column.heading))
.unwrap_or_default();
let right = format!("{} item(s){column}", app.items.len());
let pad = area
.width
.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)",
"When (phrase or RFC3339)",
"Alarm (phrase or RFC3339)",
"Numeric value",
"Recurrence (daily/weekly/monthly/every N days)",
];
let lines = names
@@ -670,7 +767,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 78, 18),
center(frame.area(), 78, 16),
"Item Properties",
Paragraph::new(lines),
colors,
@@ -689,7 +786,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Rule condition",
"Rule action",
],
"Rule: category=Work and priority<=2 → assign:Important",
"Condition files live; action fires on entry (numeric:Hours=7.5)",
),
FormKind::View(_) => (
"Live View Definition",
@@ -700,7 +797,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Boolean filter",
"Sort key",
"Show done (yes/no)",
"Columns (field:width:heading:aggregate, …)",
"Columns (numeric[Hours]:width:heading:aggregate:…)",
"Sections (heading|filter; …)",
],
"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)",
"Evening time (HH:MM)",
"Note tab width (1-16)",
"Report headers and footers (yes/no)",
],
"These settings travel with the SQLite document.",
),
@@ -927,7 +1025,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
Mode::Menu => {
popup(
frame,
center(frame.area(), 58, 15),
center(frame.area(), 64, 17),
"Rogue Agenda Menu",
Paragraph::new(vec![
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(" d Document settings (.agnd)"),
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(" t Empty Trash permanently"),
Line::from(" q Quit"),
@@ -947,6 +1047,61 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
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, .. } => {
popup(
frame,
@@ -1346,7 +1501,7 @@ mod tests {
"priority",
true,
"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",
)
.unwrap();
@@ -1372,7 +1527,8 @@ mod tests {
assert!(screen.contains("Urgent work"));
assert!(screen.contains("Action"));
assert!(screen.contains("Cost"));
assert!(screen.contains("sum 75.00"));
assert!(screen.contains("Total 75.00"));
assert!(screen.contains("100.00%"));
}
#[test]
@@ -1527,7 +1683,6 @@ mod tests {
.collect::<String>();
assert!(screen.contains("> Configured display item"));
assert!(screen.contains("08/17/2026 3:30pm"));
assert!(screen.contains("1.234,50"));
assert!(!screen.contains("Cat Mgr"));
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.