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:
276
src/app.rs
276
src/app.rs
@@ -11,13 +11,14 @@ use ratatui::layout::Rect;
|
||||
|
||||
use crate::{
|
||||
db::Database,
|
||||
filter,
|
||||
macro_lang::{
|
||||
MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name,
|
||||
parse_key_binding,
|
||||
},
|
||||
model::{
|
||||
Category, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef,
|
||||
TrashPolicy, ViewDef, ViewKind, WeekStart,
|
||||
Category, CategoryKind, DateOrder, DocumentSettings, DonePolicy, Item, ItemChanges,
|
||||
MacroDef, TrashPolicy, ViewDef, ViewKind, WeekStart,
|
||||
},
|
||||
preferences::AppPreferences,
|
||||
};
|
||||
@@ -41,6 +42,11 @@ pub enum InputKind {
|
||||
MacroEditor(Option<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(§ion.filter_expr, item).unwrap_or(false)
|
||||
})
|
||||
});
|
||||
self.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
section.is_some_and(|section| {
|
||||
filter::matches(§ion.filter_expr, item).unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.map(|item| item.id)
|
||||
.collect()
|
||||
}
|
||||
3 => self.items.iter().map(|item| item.id).collect(),
|
||||
_ => self.db.all_item_ids()?,
|
||||
};
|
||||
let summary = self.db.execute_rules(&ids)?;
|
||||
self.refresh()?;
|
||||
self.mode = Mode::Normal;
|
||||
self.status = format!(
|
||||
"Executed conditions/actions for {} item(s) — {} conflict(s)",
|
||||
summary.items, summary.conflicts
|
||||
);
|
||||
}
|
||||
_ => self.mode = Mode::Execute { selected },
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let Mode::Confirm { action, .. } = self.mode.clone() else {
|
||||
return Ok(());
|
||||
@@ -1483,6 +1632,7 @@ impl App {
|
||||
settings.afternoon_time.clone(),
|
||||
settings.evening_time.clone(),
|
||||
settings.note_tab_width.to_string(),
|
||||
yes_no(settings.report_headers),
|
||||
],
|
||||
field: 0,
|
||||
});
|
||||
@@ -1701,6 +1851,7 @@ impl App {
|
||||
note_tab_width: form.values[11]
|
||||
.parse()
|
||||
.context("note tab width must be a number")?,
|
||||
report_headers: parse_yes_no(&form.values[12])?,
|
||||
};
|
||||
self.db.save_document_settings(&settings)?;
|
||||
self.document_settings = settings;
|
||||
@@ -1856,6 +2007,13 @@ impl App {
|
||||
if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
|
||||
self.selected = pos;
|
||||
}
|
||||
if let Some((_, column)) = self
|
||||
.item_columns
|
||||
.iter()
|
||||
.find(|(region, _)| inside(*region, x, y))
|
||||
{
|
||||
self.selected_column = *column;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) {
|
||||
@@ -2092,7 +2250,6 @@ mod tests {
|
||||
"not-a-priority".into(),
|
||||
"not-a-date".into(),
|
||||
String::new(),
|
||||
"not-a-number".into(),
|
||||
String::new(),
|
||||
],
|
||||
field: 0,
|
||||
@@ -2106,4 +2263,55 @@ mod tests {
|
||||
assert!(app.items[0].when_at.is_none());
|
||||
assert!(app.items[0].numeric_value.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_numeric_cells_feed_macros_and_edit_the_bound_category() {
|
||||
let directory = tempdir().unwrap();
|
||||
let path = directory.path().join("cell-context.agnd");
|
||||
let mut db = Database::open(&path).unwrap();
|
||||
let hours = db
|
||||
.save_category(None, "Hours", None, "numeric", "", false)
|
||||
.unwrap();
|
||||
let item = db.add_item("Consulting").unwrap();
|
||||
db.set_numeric_value(item, hours, Some(12.5)).unwrap();
|
||||
let view_id = db
|
||||
.save_view_design(
|
||||
None,
|
||||
"Hours",
|
||||
"list",
|
||||
"",
|
||||
"manual",
|
||||
true,
|
||||
"",
|
||||
"item:60:Item,numeric[Hours]:20:Hours",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = App::new_with_preferences(
|
||||
db,
|
||||
path,
|
||||
AppPreferences::default(),
|
||||
directory.path().join("preferences.toml"),
|
||||
)
|
||||
.unwrap();
|
||||
app.view_index = app
|
||||
.views
|
||||
.iter()
|
||||
.position(|view| view.id == view_id)
|
||||
.unwrap();
|
||||
app.refresh().unwrap();
|
||||
app.handle_key(key(KeyCode::Right)).unwrap();
|
||||
let context = app.macro_context();
|
||||
assert_eq!(context.highlight_type, "NUMERIC");
|
||||
assert_eq!(context.highlight_value, "12.5");
|
||||
|
||||
app.handle_key(key(KeyCode::F(2))).unwrap();
|
||||
let Mode::Input(mut input) = app.mode.clone() else {
|
||||
panic!("numeric cell did not open a value editor");
|
||||
};
|
||||
input.value = "20".into();
|
||||
input.cursor = 2;
|
||||
app.accept_input(input).unwrap();
|
||||
assert_eq!(app.items[0].numeric_value_for("Hours"), Some(20.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
168
src/db/schema.rs
168
src/db/schema.rs
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
371
src/ui.rs
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user