complete category-scoped values, rules, and report parity

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

View File

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

View File

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

1246
src/db.rs

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

371
src/ui.rs
View File

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