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:
18
PRESETS.md
18
PRESETS.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
95
PROJECT.md
95
PROJECT.md
@@ -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 item–numeric-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 item–numeric-category assignment instead of
|
- [x] Store a separate value for every item–numeric-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.
|
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -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
|
||||||
|
|||||||
276
src/app.rs
276
src/app.rs
@@ -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(§ion.filter_expr, item).unwrap_or(false)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
self.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
section.is_some_and(|section| {
|
||||||
|
filter::matches(§ion.filter_expr, item).unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|item| item.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
3 => self.items.iter().map(|item| item.id).collect(),
|
||||||
|
_ => self.db.all_item_ids()?,
|
||||||
|
};
|
||||||
|
let summary = self.db.execute_rules(&ids)?;
|
||||||
|
self.refresh()?;
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
self.status = format!(
|
||||||
|
"Executed conditions/actions for {} item(s) — {} conflict(s)",
|
||||||
|
summary.items, summary.conflicts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => self.mode = Mode::Execute { selected },
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> {
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
212
src/db/export.rs
212
src/db/export.rs
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
168
src/db/schema.rs
168
src/db/schema.rs
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
367
src/ui.rs
367
src/ui.rs
@@ -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()
|
.iter()
|
||||||
.find(|c| c.field == "item")
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| columns[0].clone());
|
.enumerate()
|
||||||
let detail = columns
|
.collect::<Vec<_>>();
|
||||||
|
if area.width < 70 && base_columns.len() > 2 {
|
||||||
|
let item = base_columns
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.field == "when")
|
.find(|(_, column)| column.field == "item")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| columns.iter().find(|c| c.field != item.field).cloned());
|
.unwrap_or_else(|| base_columns[0].clone());
|
||||||
columns = vec![item];
|
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 {
|
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()
|
||||||
Style::default()
|
|
||||||
.fg(colors.selection_foreground)
|
|
||||||
.bg(colors.selection)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
.fg(if item.done_at.is_some() {
|
.fg(if item.done_at.is_some() {
|
||||||
Color::DarkGray
|
Color::DarkGray
|
||||||
} else {
|
} else {
|
||||||
colors.foreground
|
colors.foreground
|
||||||
})
|
})
|
||||||
.bg(colors.canvas)
|
.bg(colors.canvas);
|
||||||
};
|
let cells = columns
|
||||||
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
|
.iter()
|
||||||
lines.push(cells_line(cells, &widths, style));
|
.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((
|
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
2
~/.local/.crates.toml
Normal 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
1
~/.local/.crates2.json
Normal 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
BIN
~/.local/bin/rogue-agenda
Executable file
Binary file not shown.
Reference in New Issue
Block a user