diff --git a/src/app.rs b/src/app.rs index b517df1..15ee4d0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,16 @@ use crate::{ preferences::AppPreferences, }; +mod editor; +mod forms; + +use editor::{ + delete_at_cursor, delete_before_cursor, insert_at_cursor, insert_str_at_cursor, line_end, + line_start, move_cursor_vertically, next_boundary, previous_boundary, +}; +pub(crate) use forms::form_choices; +use forms::{decode_space, display_space, form_prompt, parse_yes, parse_yes_no, yes_no}; + #[derive(Debug, Clone)] pub enum InputKind { NewItem, @@ -132,32 +142,32 @@ pub enum ConfirmAction { } pub struct App { - pub db: Database, - pub path: PathBuf, - pub preferences: AppPreferences, - pub preferences_path: PathBuf, - pub document_settings: DocumentSettings, - pub views: Vec, - pub view_index: usize, - pub items: Vec, - pub selected: usize, - pub scroll: usize, - pub search: String, - pub marked: HashSet, - pub categories: Vec, - pub category_selected: usize, - pub view_selected: usize, - pub mode: Mode, - pub status: String, - pub should_quit: bool, - pub item_rows: Vec<(Rect, i64)>, - pub command_regions: Vec, - pub view_regions: Vec, - pub choice_regions: Vec, - pub macros: Vec, - pub macro_selected: usize, - pub recording: Option, - pub macro_capture_key: bool, + pub(crate) db: Database, + pub(crate) path: PathBuf, + pub(crate) preferences: AppPreferences, + pub(crate) preferences_path: PathBuf, + pub(crate) document_settings: DocumentSettings, + pub(crate) views: Vec, + pub(crate) view_index: usize, + pub(crate) items: Vec, + pub(crate) selected: usize, + pub(crate) scroll: usize, + pub(crate) search: String, + pub(crate) marked: HashSet, + pub(crate) categories: Vec, + pub(crate) category_selected: usize, + pub(crate) view_selected: usize, + pub(crate) mode: Mode, + pub(crate) status: String, + pub(crate) should_quit: bool, + pub(crate) item_rows: Vec<(Rect, i64)>, + pub(crate) command_regions: Vec, + pub(crate) view_regions: Vec, + pub(crate) choice_regions: Vec, + pub(crate) macros: Vec, + pub(crate) macro_selected: usize, + pub(crate) recording: Option, + pub(crate) macro_capture_key: bool, macro_runtime: Option, macro_globals: std::collections::HashMap, macro_return_mode: Option, @@ -248,6 +258,10 @@ impl App { self.items.get(self.selected) } + pub fn should_quit(&self) -> bool { + self.should_quit + } + pub fn refresh(&mut self) -> Result<()> { let keep = self.selected_item().map(|i| i.id); self.views = self.db.views()?; @@ -1858,143 +1872,6 @@ impl App { } } -fn yes_no(value: bool) -> String { - if value { "yes" } else { "no" }.into() -} -fn parse_yes(value: &str) -> bool { - matches!( - value.trim().to_lowercase().as_str(), - "y" | "yes" | "true" | "1" | "on" - ) -} - -fn parse_yes_no(value: &str) -> Result { - match value.trim().to_lowercase().as_str() { - "y" | "yes" | "true" | "1" | "on" => Ok(true), - "n" | "no" | "false" | "0" | "off" => Ok(false), - _ => bail!("expected yes or no, got {value}"), - } -} - -fn display_space(value: &str) -> String { - if value == " " { "space" } else { value }.into() -} - -fn decode_space(value: &str) -> String { - if value.eq_ignore_ascii_case("space") { - " ".into() - } else { - value.into() - } -} - -pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option> { - let entries: &[(&str, &str)] = match kind { - FormKind::Preferences => match field { - 0 => &[ - ("Classic — Lotus-inspired blue", "classic"), - ("Mono — grayscale", "mono"), - ("Amber — warm dark", "amber"), - ("Greenscreen — muted phosphor green", "greenscreen"), - ("Nord — arctic blue dark", "nord"), - ("Catppuccin Mocha — cozy pastel dark", "catppuccin-mocha"), - ], - 1..=3 | 6 | 8 => YES_NO_CHOICES, - 7 => &[ - ("ISO — 2026-08-16", "iso"), - ("US — 08/16/2026", "us"), - ("European — 16/08/2026", "european"), - ("Long — 16 Aug 2026", "long"), - ], - _ => return None, - }, - FormKind::DocumentSettings => match field { - 1 | 4 => YES_NO_CHOICES, - 2 => &[ - ("On demand", "on-demand"), - ("On close", "on-close"), - ("End of day", "end-of-day"), - ("Immediately", "immediate"), - ], - 3 => &[ - ("Keep completed items", "keep"), - ("Move them to Trash", "trash"), - ], - 5 => &[ - ("Year / month / day", "ymd"), - ("Month / day / year", "mdy"), - ("Day / month / year", "dmy"), - ], - 6 => &[("Monday", "monday"), ("Sunday", "sunday")], - _ => return None, - }, - FormKind::Category(_) | FormKind::View(_) => return None, - }; - Some( - entries - .iter() - .map(|(label, value)| ChoiceOption { - label: (*label).into(), - value: (*value).into(), - }) - .collect(), - ) -} - -fn form_prompt(kind: &FormKind, field: usize) -> &'static str { - let prompts: &[&str] = match kind { - FormKind::Category(_) => &[ - "Name", - "Parent category", - "Kind", - "Match phrases", - "Mutually exclusive", - "Rule condition", - "Rule action", - ], - FormKind::View(_) => &[ - "Name", - "Kind", - "Filter value", - "Boolean filter", - "Sort key", - "Show done", - "Columns", - "Sections", - ], - FormKind::Preferences => &[ - "Theme", - "Show command bar", - "Show rule info", - "Show return markers", - "Item marker", - "Autosave minutes", - "Confirm destructive actions", - "Date format", - "24-hour clock", - "Decimal separator", - "Thousands separator", - ], - FormKind::DocumentSettings => &[ - "Description", - "Backup on open", - "Trash", - "Completed items", - "Automatic filing", - "Numeric date order", - "Week starts", - "Default time", - "Morning time", - "Afternoon time", - "Evening time", - "Note tab width", - ], - }; - prompts.get(field).copied().unwrap_or("") -} - -const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")]; - fn normalize_date(db: &Database, value: &str) -> Result> { let s = value.trim(); if s.is_empty() { @@ -2027,87 +1904,6 @@ fn is_legacy_key_capture_shortcut(key: KeyEvent) -> bool { key.code == KeyCode::Char('=') && key.modifiers.contains(KeyModifiers::ALT) } -fn insert_at_cursor(input: &mut InputState, c: char) { - input.value.insert(input.cursor, c); - input.cursor += c.len_utf8(); -} - -fn insert_str_at_cursor(input: &mut InputState, value: &str) { - input.value.insert_str(input.cursor, value); - input.cursor += value.len(); -} - -fn delete_before_cursor(input: &mut InputState) { - let previous = previous_boundary(&input.value, input.cursor); - if previous != input.cursor { - input.value.drain(previous..input.cursor); - input.cursor = previous; - } -} - -fn delete_at_cursor(input: &mut InputState) { - let next = next_boundary(&input.value, input.cursor); - if next != input.cursor { - input.value.drain(input.cursor..next); - } -} - -fn previous_boundary(value: &str, cursor: usize) -> usize { - value[..cursor] - .char_indices() - .next_back() - .map_or(0, |(index, _)| index) -} - -fn next_boundary(value: &str, cursor: usize) -> usize { - value[cursor..] - .chars() - .next() - .map_or(cursor, |c| cursor + c.len_utf8()) -} - -fn line_start(value: &str, cursor: usize) -> usize { - value[..cursor].rfind('\n').map_or(0, |index| index + 1) -} - -fn line_end(value: &str, cursor: usize) -> usize { - value[cursor..] - .find('\n') - .map_or(value.len(), |index| cursor + index) -} - -fn byte_at_character(value: &str, start: usize, end: usize, column: usize) -> usize { - value[start..end] - .char_indices() - .nth(column) - .map_or(end, |(offset, _)| start + offset) -} - -fn move_cursor_vertically(input: &mut InputState, delta: isize) { - let start = line_start(&input.value, input.cursor); - let end = line_end(&input.value, input.cursor); - let column = input.value[start..input.cursor].chars().count(); - input.cursor = if delta < 0 { - if start == 0 { - input.cursor - } else { - let target_end = start - 1; - let target_start = input.value[..target_end] - .rfind('\n') - .map_or(0, |index| index + 1); - byte_at_character(&input.value, target_start, target_end, column) - } - } else if end == input.value.len() { - input.cursor - } else { - let target_start = end + 1; - let target_end = input.value[target_start..] - .find('\n') - .map_or(input.value.len(), |index| target_start + index); - byte_at_character(&input.value, target_start, target_end, column) - }; -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/app/editor.rs b/src/app/editor.rs new file mode 100644 index 0000000..2258c90 --- /dev/null +++ b/src/app/editor.rs @@ -0,0 +1,82 @@ +use super::InputState; + +pub(super) fn insert_at_cursor(input: &mut InputState, character: char) { + input.value.insert(input.cursor, character); + input.cursor += character.len_utf8(); +} + +pub(super) fn insert_str_at_cursor(input: &mut InputState, value: &str) { + input.value.insert_str(input.cursor, value); + input.cursor += value.len(); +} + +pub(super) fn delete_before_cursor(input: &mut InputState) { + let previous = previous_boundary(&input.value, input.cursor); + if previous != input.cursor { + input.value.drain(previous..input.cursor); + input.cursor = previous; + } +} + +pub(super) fn delete_at_cursor(input: &mut InputState) { + let next = next_boundary(&input.value, input.cursor); + if next != input.cursor { + input.value.drain(input.cursor..next); + } +} + +pub(super) fn previous_boundary(value: &str, cursor: usize) -> usize { + value[..cursor] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index) +} + +pub(super) fn next_boundary(value: &str, cursor: usize) -> usize { + value[cursor..] + .chars() + .next() + .map_or(cursor, |character| cursor + character.len_utf8()) +} + +pub(super) fn line_start(value: &str, cursor: usize) -> usize { + value[..cursor].rfind('\n').map_or(0, |index| index + 1) +} + +pub(super) fn line_end(value: &str, cursor: usize) -> usize { + value[cursor..] + .find('\n') + .map_or(value.len(), |index| cursor + index) +} + +fn byte_at_character(value: &str, start: usize, end: usize, column: usize) -> usize { + value[start..end] + .char_indices() + .nth(column) + .map_or(end, |(offset, _)| start + offset) +} + +pub(super) fn move_cursor_vertically(input: &mut InputState, delta: isize) { + let start = line_start(&input.value, input.cursor); + let end = line_end(&input.value, input.cursor); + let column = input.value[start..input.cursor].chars().count(); + input.cursor = if delta < 0 { + if start == 0 { + input.cursor + } else { + let target_end = start - 1; + let target_start = input.value[..target_end] + .rfind('\n') + .map_or(0, |index| index + 1); + byte_at_character(&input.value, target_start, target_end, column) + } + } else if end == input.value.len() { + input.cursor + } else { + let target_start = end + 1; + let target_end = input.value[target_start..] + .find('\n') + .map_or(input.value.len(), |index| target_start + index); + byte_at_character(&input.value, target_start, target_end, column) + }; +} diff --git a/src/app/forms.rs b/src/app/forms.rs new file mode 100644 index 0000000..5d06b60 --- /dev/null +++ b/src/app/forms.rs @@ -0,0 +1,141 @@ +use anyhow::{Result, bail}; + +use super::{ChoiceOption, FormKind}; + +const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")]; + +pub(super) fn yes_no(value: bool) -> String { + if value { "yes" } else { "no" }.into() +} + +pub(super) fn parse_yes(value: &str) -> bool { + matches!( + value.trim().to_lowercase().as_str(), + "y" | "yes" | "true" | "1" | "on" + ) +} + +pub(super) fn parse_yes_no(value: &str) -> Result { + match value.trim().to_lowercase().as_str() { + "y" | "yes" | "true" | "1" | "on" => Ok(true), + "n" | "no" | "false" | "0" | "off" => Ok(false), + _ => bail!("expected yes or no, got {value}"), + } +} + +pub(super) fn display_space(value: &str) -> String { + if value == " " { "space" } else { value }.into() +} + +pub(super) fn decode_space(value: &str) -> String { + if value.eq_ignore_ascii_case("space") { + " ".into() + } else { + value.into() + } +} + +pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option> { + let entries: &[(&str, &str)] = match kind { + FormKind::Preferences => match field { + 0 => &[ + ("Classic — Lotus-inspired blue", "classic"), + ("Mono — grayscale", "mono"), + ("Amber — warm dark", "amber"), + ("Greenscreen — muted phosphor green", "greenscreen"), + ("Nord — arctic blue dark", "nord"), + ("Catppuccin Mocha — cozy pastel dark", "catppuccin-mocha"), + ], + 1..=3 | 6 | 8 => YES_NO_CHOICES, + 7 => &[ + ("ISO — 2026-08-16", "iso"), + ("US — 08/16/2026", "us"), + ("European — 16/08/2026", "european"), + ("Long — 16 Aug 2026", "long"), + ], + _ => return None, + }, + FormKind::DocumentSettings => match field { + 1 | 4 => YES_NO_CHOICES, + 2 => &[ + ("On demand", "on-demand"), + ("On close", "on-close"), + ("End of day", "end-of-day"), + ("Immediately", "immediate"), + ], + 3 => &[ + ("Keep completed items", "keep"), + ("Move them to Trash", "trash"), + ], + 5 => &[ + ("Year / month / day", "ymd"), + ("Month / day / year", "mdy"), + ("Day / month / year", "dmy"), + ], + 6 => &[("Monday", "monday"), ("Sunday", "sunday")], + _ => return None, + }, + FormKind::Category(_) | FormKind::View(_) => return None, + }; + Some( + entries + .iter() + .map(|(label, value)| ChoiceOption { + label: (*label).into(), + value: (*value).into(), + }) + .collect(), + ) +} + +pub(super) fn form_prompt(kind: &FormKind, field: usize) -> &'static str { + let prompts: &[&str] = match kind { + FormKind::Category(_) => &[ + "Name", + "Parent category", + "Kind", + "Match phrases", + "Mutually exclusive", + "Rule condition", + "Rule action", + ], + FormKind::View(_) => &[ + "Name", + "Kind", + "Filter value", + "Boolean filter", + "Sort key", + "Show done", + "Columns", + "Sections", + ], + FormKind::Preferences => &[ + "Theme", + "Show command bar", + "Show rule info", + "Show return markers", + "Item marker", + "Autosave minutes", + "Confirm destructive actions", + "Date format", + "24-hour clock", + "Decimal separator", + "Thousands separator", + ], + FormKind::DocumentSettings => &[ + "Description", + "Backup on open", + "Trash", + "Completed items", + "Automatic filing", + "Numeric date order", + "Week starts", + "Default time", + "Morning time", + "Afternoon time", + "Evening time", + "Note tab width", + ], + }; + prompts.get(field).copied().unwrap_or("") +} diff --git a/src/db.rs b/src/db.rs index 61427c3..f353f8f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,21 +6,24 @@ use std::{ }; use anyhow::{Context, Result, bail}; -use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone}; use rusqlite::{Connection, OptionalExtension, params}; -use crate::date::{DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence}; +use crate::date::{DateParseConfig, extract_when_configured, next_occurrence}; use crate::filter; use crate::model::{ Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection, }; -mod presets; +mod export; mod import; +mod presets; +mod schema; pub use presets::Preset; +use self::export::{csv, json, opt_json, render_html, render_ical, render_markdown}; use self::import::{IcalRecord, ical_unescape, parse_ical_date, rrule_to_recurrence}; fn enum_column(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result @@ -60,7 +63,7 @@ impl Database { path: path.to_owned(), new_document: !existed, }; - db.migrate()?; + schema::migrate(&db.conn)?; db.seed_defaults()?; if existed && db.document_settings()?.backup_on_open { db.backup_now()?; @@ -68,129 +71,6 @@ impl Database { Ok(db) } - fn migrate(&mut self) -> Result<()> { - self.conn.execute_batch( - "BEGIN; - CREATE TABLE IF NOT EXISTS meta ( - key TEXT PRIMARY KEY, value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS items ( - id INTEGER PRIMARY KEY, - text TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5), - 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 CHECK(discarded IN (0,1)) - ); - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - kind TEXT NOT NULL DEFAULT 'standard', - match_text TEXT NOT NULL DEFAULT '', - exclusive INTEGER NOT NULL DEFAULT 0 CHECK(exclusive IN (0,1)), - sort_order INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS 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 DEFAULT 'explicit', - PRIMARY KEY(item_id, category_id) - ); - CREATE TABLE IF NOT EXISTS dependencies ( - item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, - prerequisite_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, - PRIMARY KEY(item_id, prerequisite_id), - CHECK(item_id <> prerequisite_id) - ); - CREATE TABLE IF NOT EXISTS views ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - 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 - ); - CREATE INDEX IF NOT EXISTS idx_items_when ON items(when_at); - CREATE INDEX IF NOT EXISTS idx_items_done ON items(done_at); - CREATE INDEX IF NOT EXISTS idx_item_categories_category ON item_categories(category_id); - COMMIT;" - )?; - let version: i64 = self - .conn - .query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version < 2 { - let has_filter_expr = { - let mut stmt = self.conn.prepare("PRAGMA table_info(views)")?; - let names = stmt - .query_map([], |r| r.get::<_, String>(1))? - .collect::>>()?; - names.iter().any(|name| name == "filter_expr") - }; - if !has_filter_expr { - self.conn.execute_batch( - "ALTER TABLE views ADD COLUMN filter_expr TEXT NOT NULL DEFAULT '';", - )?; - } - } - self.conn.execute_batch( - "CREATE TABLE IF NOT EXISTS view_columns ( - id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, - 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); - CREATE TABLE IF NOT EXISTS view_sections ( - id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, - heading TEXT NOT NULL, filter_expr TEXT NOT NULL DEFAULT '', collapsed INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0); - CREATE TABLE IF NOT EXISTS category_rules ( - id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, - condition_expr TEXT NOT NULL, action_kind TEXT NOT NULL, action_value TEXT NOT NULL DEFAULT '', - enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0);", - )?; - if version < 2 { - self.conn.pragma_update(None, "user_version", 2)?; - } - if version < 3 { - let has_recurrence = { - let mut stmt = self.conn.prepare("PRAGMA table_info(items)")?; - let names = stmt - .query_map([], |r| r.get::<_, String>(1))? - .collect::>>()?; - names.iter().any(|name| name == "recurrence") - }; - if !has_recurrence { - self.conn.execute_batch( - "ALTER TABLE items ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';", - )?; - } - self.conn.pragma_update(None, "user_version", 3)?; - } - self.conn.execute_batch( - "CREATE TABLE IF NOT EXISTS macros ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - source TEXT NOT NULL, - key_binding TEXT NOT NULL DEFAULT '', - sort_order INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS macro_variables ( - name TEXT PRIMARY KEY COLLATE NOCASE, - value TEXT NOT NULL - );", - )?; - if version < 4 { - self.conn.pragma_update(None, "user_version", 4)?; - } - Ok(()) - } - pub fn macros(&self) -> Result> { let mut statement = self .conn @@ -1486,198 +1366,6 @@ fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> O .earliest() .map(|date_time| date_time.to_rfc3339()) } -fn ical_escape(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('\n', "\\n") - .replace(',', "\\,") - .replace(';', "\\;") -} -fn recurrence_to_rrule(value: &str) -> Option { - let value = value.trim().to_lowercase(); - if value.is_empty() { - return None; - } - Some(match value.as_str() { - "daily" => "FREQ=DAILY".into(), - "weekdays" => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into(), - "weekly" => "FREQ=WEEKLY".into(), - "monthly" => "FREQ=MONTHLY".into(), - "yearly" | "annually" => "FREQ=YEARLY".into(), - _ => { - let c = RECURRENCE_INTERVAL.captures(&value)?; - if c[1].parse::().ok()? == 0 { - return None; - } - let freq = match &c[2] { - "day" | "days" => "DAILY", - "week" | "weeks" => "WEEKLY", - "month" | "months" => "MONTHLY", - _ => "YEARLY", - }; - format!("FREQ={freq};INTERVAL={}", &c[1]) - } - }) -} -fn render_ical(items: &[Item]) -> String { - let mut out = String::from( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n", - ); - for item in items { - out.push_str("BEGIN:VTODO\r\n"); - out.push_str(&format!("UID:rogue-{}@local\r\n", item.id)); - out.push_str(&format!( - "DTSTAMP:{}\r\n", - ical_timestamp(&item.created_at) - .unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string()) - )); - out.push_str(&format!("SUMMARY:{}\r\n", ical_escape(&item.text))); - if !item.note.is_empty() { - out.push_str(&format!("DESCRIPTION:{}\r\n", ical_escape(&item.note))); - } - if let Some(when_at) = item.when_at.as_deref().and_then(ical_timestamp) { - out.push_str(&format!("DUE:{when_at}\r\n")); - } - out.push_str(&format!("PRIORITY:{}\r\n", item.priority)); - if !item.categories.is_empty() { - out.push_str(&format!( - "CATEGORIES:{}\r\n", - item.categories - .iter() - .map(|c| ical_escape(&c.name)) - .collect::>() - .join(",") - )); - } - if let Some(rrule) = recurrence_to_rrule(&item.recurrence) { - out.push_str(&format!("RRULE:{rrule}\r\n")); - } - if let Some(done) = item.done_at.as_deref() { - out.push_str("STATUS:COMPLETED\r\n"); - if let Some(done) = ical_timestamp(done) { - out.push_str(&format!("COMPLETED:{done}\r\n")); - } - } else { - out.push_str("STATUS:NEEDS-ACTION\r\n"); - } - out.push_str("END:VTODO\r\n"); - } - out.push_str("END:VCALENDAR\r\n"); - out -} -fn ical_timestamp(value: &str) -> Option { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|d| d.with_timezone(&Utc).format("%Y%m%dT%H%M%SZ").to_string()) -} - -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())]; - } - view.sections - .iter() - .map(|section| { - ( - section.heading.clone(), - items - .iter() - .filter(|item| filter::matches(§ion.filter_expr, item).unwrap_or(false)) - .collect(), - ) - }) - .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(|v| v.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(), - } -} -fn render_markdown(view: &ViewDef, items: &[Item]) -> String { - let mut out = format!("# Rogue Agenda — {}\n\n", view.name); - for (heading, group) in report_groups(view, items) { - if !heading.is_empty() { - out.push_str(&format!("## {}\n\n", markdown(&heading))); - } - out.push('|'); - for column in &view.columns { - out.push_str(&format!(" {} |", markdown(&column.heading))); - } - out.push('\n'); - out.push('|'); - for _ in &view.columns { - out.push_str(" --- |"); - } - out.push('\n'); - for item in group { - out.push('|'); - for column in &view.columns { - out.push_str(&format!( - " {} |", - markdown(&report_value(&column.field, item)) - )); - } - out.push('\n'); - } - out.push('\n'); - } - out -} -fn render_html(view: &ViewDef, items: &[Item]) -> String { - let mut out = format!( - "Rogue Agenda — {}

Rogue Agenda — {}

", - html(&view.name), - html(&view.name) - ); - for (heading, group) in report_groups(view, items) { - if !heading.is_empty() { - out.push_str(&format!("

{}

", html(&heading))); - } - out.push_str(""); - for column in &view.columns { - out.push_str(&format!("", html(&column.heading))); - } - out.push_str(""); - for item in group { - out.push_str(""); - for column in &view.columns { - out.push_str(&format!( - "", - html(&report_value(&column.field, item)).replace('\n', "
") - )); - } - out.push_str(""); - } - out.push_str("
{}
{}
"); - } - out.push_str("\n"); - out -} -fn markdown(value: &str) -> String { - value.replace('|', "\\|").replace('\n', "
") -} -fn html(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - fn default_columns() -> Vec { [ ("item", "Items", 48), @@ -1839,22 +1527,6 @@ fn default_heading(field: &str) -> &str { } } -fn json(s: &str) -> String { - format!( - "\"{}\"", - s.replace('\\', "\\\\") - .replace('\"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") - ) -} -fn opt_json(s: Option<&str>) -> String { - s.map(json).unwrap_or_else(|| "null".into()) -} -fn csv(s: &str) -> String { - format!("\"{}\"", s.replace('\"', "\"\"")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/db/export.rs b/src/db/export.rs new file mode 100644 index 0000000..f99af1e --- /dev/null +++ b/src/db/export.rs @@ -0,0 +1,229 @@ +use chrono::{DateTime, Utc}; + +use crate::{ + date::RECURRENCE_INTERVAL, + filter, + model::{Item, ViewDef}, +}; + +pub(super) fn render_ical(items: &[Item]) -> String { + let mut output = String::from( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n", + ); + for item in items { + output.push_str("BEGIN:VTODO\r\n"); + output.push_str(&format!("UID:rogue-{}@local\r\n", item.id)); + output.push_str(&format!( + "DTSTAMP:{}\r\n", + ical_timestamp(&item.created_at) + .unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string()) + )); + output.push_str(&format!("SUMMARY:{}\r\n", ical_escape(&item.text))); + if !item.note.is_empty() { + output.push_str(&format!("DESCRIPTION:{}\r\n", ical_escape(&item.note))); + } + if let Some(when_at) = item.when_at.as_deref().and_then(ical_timestamp) { + output.push_str(&format!("DUE:{when_at}\r\n")); + } + output.push_str(&format!("PRIORITY:{}\r\n", item.priority)); + if !item.categories.is_empty() { + output.push_str(&format!( + "CATEGORIES:{}\r\n", + item.categories + .iter() + .map(|category| ical_escape(&category.name)) + .collect::>() + .join(",") + )); + } + if let Some(rule) = recurrence_to_rrule(&item.recurrence) { + output.push_str(&format!("RRULE:{rule}\r\n")); + } + if let Some(done) = item.done_at.as_deref() { + output.push_str("STATUS:COMPLETED\r\n"); + if let Some(done) = ical_timestamp(done) { + output.push_str(&format!("COMPLETED:{done}\r\n")); + } + } else { + output.push_str("STATUS:NEEDS-ACTION\r\n"); + } + output.push_str("END:VTODO\r\n"); + } + output.push_str("END:VCALENDAR\r\n"); + output +} + +fn ical_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace(',', "\\,") + .replace(';', "\\;") +} + +fn ical_timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value).ok().map(|date_time| { + date_time + .with_timezone(&Utc) + .format("%Y%m%dT%H%M%SZ") + .to_string() + }) +} + +fn recurrence_to_rrule(value: &str) -> Option { + let value = value.trim().to_lowercase(); + if value.is_empty() { + return None; + } + Some(match value.as_str() { + "daily" => "FREQ=DAILY".into(), + "weekdays" => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into(), + "weekly" => "FREQ=WEEKLY".into(), + "monthly" => "FREQ=MONTHLY".into(), + "yearly" | "annually" => "FREQ=YEARLY".into(), + _ => { + let captures = RECURRENCE_INTERVAL.captures(&value)?; + if captures[1].parse::().ok()? == 0 { + return None; + } + let frequency = match &captures[2] { + "day" | "days" => "DAILY", + "week" | "weeks" => "WEEKLY", + "month" | "months" => "MONTHLY", + _ => "YEARLY", + }; + format!("FREQ={frequency};INTERVAL={}", &captures[1]) + } + }) +} + +pub(super) fn render_markdown(view: &ViewDef, items: &[Item]) -> String { + let mut output = format!("# Rogue Agenda — {}\n\n", view.name); + 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))); + } + output.push('\n'); + output.push('|'); + for _ in &view.columns { + output.push_str(" --- |"); + } + output.push('\n'); + for item in group { + output.push('|'); + for column in &view.columns { + output.push_str(&format!( + " {} |", + markdown(&report_value(&column.field, item)) + )); + } + output.push('\n'); + } + output.push('\n'); + } + output +} + +pub(super) fn render_html(view: &ViewDef, items: &[Item]) -> String { + let mut output = format!( + "Rogue Agenda — {}

Rogue Agenda — {}

", + html(&view.name), + html(&view.name) + ); + for (heading, group) in report_groups(view, items) { + if !heading.is_empty() { + output.push_str(&format!("

{}

", html(&heading))); + } + output.push_str(""); + for column in &view.columns { + output.push_str(&format!("", html(&column.heading))); + } + output.push_str(""); + for item in group { + output.push_str(""); + for column in &view.columns { + output.push_str(&format!( + "", + html(&report_value(&column.field, item)).replace('\n', "
") + )); + } + output.push_str(""); + } + output.push_str("
{}
{}
"); + } + output.push_str("\n"); + output +} + +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())]; + } + view.sections + .iter() + .map(|section| { + ( + section.heading.clone(), + items + .iter() + .filter(|item| filter::matches(§ion.filter_expr, item).unwrap_or(false)) + .collect(), + ) + }) + .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(), + } +} + +fn markdown(value: &str) -> String { + value.replace('|', "\\|").replace('\n', "
") +} + +fn html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +pub(super) fn json(value: &str) -> String { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + ) +} + +pub(super) fn opt_json(value: Option<&str>) -> String { + value.map(json).unwrap_or_else(|| "null".into()) +} + +pub(super) fn csv(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/src/db/import.rs b/src/db/import.rs index 5fd2fa9..ae8bb61 100644 --- a/src/db/import.rs +++ b/src/db/import.rs @@ -17,9 +17,7 @@ pub(super) fn parse_ical_date(value: &str) -> Option { return Some(date_time.to_rfc3339()); } if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") { - return Some( - DateTime::::from_naive_utc_and_offset(date_time, Utc).to_rfc3339(), - ); + return Some(DateTime::::from_naive_utc_and_offset(date_time, Utc).to_rfc3339()); } if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { return Local diff --git a/src/db/schema.rs b/src/db/schema.rs new file mode 100644 index 0000000..6d01ad9 --- /dev/null +++ b/src/db/schema.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use rusqlite::Connection; + +pub(super) fn migrate(connection: &Connection) -> Result<()> { + connection.execute_batch( + "BEGIN; + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + text TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5), + 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 CHECK(discarded IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + kind TEXT NOT NULL DEFAULT 'standard', + match_text TEXT NOT NULL DEFAULT '', + exclusive INTEGER NOT NULL DEFAULT 0 CHECK(exclusive IN (0,1)), + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS 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 DEFAULT 'explicit', + PRIMARY KEY(item_id, category_id) + ); + CREATE TABLE IF NOT EXISTS dependencies ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + prerequisite_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY(item_id, prerequisite_id), + CHECK(item_id <> prerequisite_id) + ); + CREATE TABLE IF NOT EXISTS views ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + 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 + ); + CREATE INDEX IF NOT EXISTS idx_items_when ON items(when_at); + CREATE INDEX IF NOT EXISTS idx_items_done ON items(done_at); + CREATE INDEX IF NOT EXISTS idx_item_categories_category ON item_categories(category_id); + COMMIT;", + )?; + + let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if version < 2 { + add_filter_expression(connection)?; + } + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS view_columns ( + id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, + 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); + CREATE TABLE IF NOT EXISTS view_sections ( + id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, + heading TEXT NOT NULL, filter_expr TEXT NOT NULL DEFAULT '', collapsed INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS category_rules ( + id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + condition_expr TEXT NOT NULL, action_kind TEXT NOT NULL, action_value TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0);", + )?; + if version < 2 { + connection.pragma_update(None, "user_version", 2)?; + } + if version < 3 { + add_recurrence(connection)?; + connection.pragma_update(None, "user_version", 3)?; + } + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS macros ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + source TEXT NOT NULL, + key_binding TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS macro_variables ( + name TEXT PRIMARY KEY COLLATE NOCASE, + value TEXT NOT NULL + );", + )?; + if version < 4 { + connection.pragma_update(None, "user_version", 4)?; + } + Ok(()) +} + +fn add_filter_expression(connection: &Connection) -> Result<()> { + if !column_exists(connection, "views", "filter_expr")? { + connection + .execute_batch("ALTER TABLE views ADD COLUMN filter_expr TEXT NOT NULL DEFAULT '';")?; + } + Ok(()) +} + +fn add_recurrence(connection: &Connection) -> Result<()> { + if !column_exists(connection, "items", "recurrence")? { + connection + .execute_batch("ALTER TABLE items ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';")?; + } + Ok(()) +} + +fn column_exists(connection: &Connection, table: &str, column: &str) -> Result { + let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?; + let names = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?; + Ok(names.iter().any(|name| name == column)) +} diff --git a/src/main.rs b/src/main.rs index 58c530d..11decc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,7 @@ fn run_tui(mut app: App) -> Result<()> { loop { app.tick()?; terminal.draw(|f| ui::draw(f, &mut app))?; - if app.should_quit { + if app.should_quit() { break; } if event::poll(Duration::from_millis(250))? { diff --git a/src/ui.rs b/src/ui.rs index 3198cf9..9115dee 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,83 +16,9 @@ use crate::{ model::{Aggregate, Item, ViewColumn, ViewKind}, }; -#[derive(Clone, Copy)] -struct Palette { - primary: Color, - accent: Color, - canvas: Color, - canvas_heading: Color, - selection: Color, - selection_foreground: Color, - foreground: Color, -} +mod theme; -fn palette(theme: &str) -> Palette { - match theme { - "mono" => Palette { - primary: Color::Black, - accent: Color::White, - canvas: Color::Gray, - canvas_heading: Color::Black, - selection: Color::DarkGray, - selection_foreground: rgb(0xffffff), - foreground: Color::Black, - }, - "amber" => Palette { - primary: Color::Black, - accent: Color::Rgb(255, 191, 0), - canvas: Color::Rgb(32, 24, 0), - canvas_heading: Color::Rgb(255, 191, 0), - selection: Color::Rgb(112, 56, 0), - selection_foreground: rgb(0xffffff), - foreground: Color::Rgb(255, 191, 0), - }, - "greenscreen" => Palette { - primary: Color::Black, - accent: rgb(0x79a86b), - canvas: rgb(0x030704), - canvas_heading: rgb(0x9ac58d), - selection: rgb(0x284b2f), - selection_foreground: rgb(0xdce8d8), - foreground: rgb(0x8fbd80), - }, - "nord" => Palette { - primary: rgb(0x2e3440), - accent: rgb(0x88c0d0), - canvas: rgb(0x3b4252), - canvas_heading: rgb(0xeceff4), - selection: rgb(0xeceff4), - selection_foreground: rgb(0x2e3440), - foreground: rgb(0xeceff4), - }, - "catppuccin-mocha" => Palette { - primary: rgb(0x181825), - accent: rgb(0xcba6f7), - canvas: rgb(0x1e1e2e), - canvas_heading: rgb(0xcdd6f4), - selection: rgb(0xcba6f7), - selection_foreground: rgb(0x11111b), - foreground: rgb(0xcdd6f4), - }, - _ => Palette { - primary: Color::Rgb(0, 0, 128), - accent: Color::Cyan, - canvas: Color::Rgb(192, 192, 192), - canvas_heading: Color::Rgb(0, 0, 128), - selection: Color::Rgb(128, 0, 0), - selection_foreground: rgb(0xffffff), - foreground: Color::Black, - }, - } -} - -const fn rgb(value: u32) -> Color { - Color::Rgb( - ((value >> 16) & 0xff) as u8, - ((value >> 8) & 0xff) as u8, - (value & 0xff) as u8, - ) -} +use theme::{Palette, palette}; pub fn draw(frame: &mut Frame<'_>, app: &mut App) { let colors = palette(&app.preferences.theme); diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..76c8f79 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,79 @@ +use ratatui::style::Color; + +#[derive(Clone, Copy)] +pub(super) struct Palette { + pub primary: Color, + pub accent: Color, + pub canvas: Color, + pub canvas_heading: Color, + pub selection: Color, + pub selection_foreground: Color, + pub foreground: Color, +} + +pub(super) fn palette(theme: &str) -> Palette { + match theme { + "mono" => Palette { + primary: Color::Black, + accent: Color::White, + canvas: Color::Gray, + canvas_heading: Color::Black, + selection: Color::DarkGray, + selection_foreground: rgb(0xffffff), + foreground: Color::Black, + }, + "amber" => Palette { + primary: Color::Black, + accent: Color::Rgb(255, 191, 0), + canvas: Color::Rgb(32, 24, 0), + canvas_heading: Color::Rgb(255, 191, 0), + selection: Color::Rgb(112, 56, 0), + selection_foreground: rgb(0xffffff), + foreground: Color::Rgb(255, 191, 0), + }, + "greenscreen" => Palette { + primary: Color::Black, + accent: rgb(0x79a86b), + canvas: rgb(0x030704), + canvas_heading: rgb(0x9ac58d), + selection: rgb(0x284b2f), + selection_foreground: rgb(0xdce8d8), + foreground: rgb(0x8fbd80), + }, + "nord" => Palette { + primary: rgb(0x2e3440), + accent: rgb(0x88c0d0), + canvas: rgb(0x3b4252), + canvas_heading: rgb(0xeceff4), + selection: rgb(0xeceff4), + selection_foreground: rgb(0x2e3440), + foreground: rgb(0xeceff4), + }, + "catppuccin-mocha" => Palette { + primary: rgb(0x181825), + accent: rgb(0xcba6f7), + canvas: rgb(0x1e1e2e), + canvas_heading: rgb(0xcdd6f4), + selection: rgb(0xcba6f7), + selection_foreground: rgb(0x11111b), + foreground: rgb(0xcdd6f4), + }, + _ => Palette { + primary: Color::Rgb(0, 0, 128), + accent: Color::Cyan, + canvas: Color::Rgb(192, 192, 192), + canvas_heading: Color::Rgb(0, 0, 128), + selection: Color::Rgb(128, 0, 0), + selection_foreground: rgb(0xffffff), + foreground: Color::Black, + }, + } +} + +pub(super) const fn rgb(value: u32) -> Color { + Color::Rgb( + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ) +} diff --git a/tests/database_workflows.rs b/tests/database_workflows.rs new file mode 100644 index 0000000..ac9f701 --- /dev/null +++ b/tests/database_workflows.rs @@ -0,0 +1,70 @@ +use rogue_agenda::{ + Database, + model::{ItemChanges, ViewKind}, +}; +use tempfile::tempdir; + +fn all_items(database: &Database) -> rogue_agenda::model::ViewDef { + database + .views() + .unwrap() + .into_iter() + .find(|view| view.kind == ViewKind::List) + .unwrap() +} + +#[test] +fn completing_a_recurring_item_preserves_wall_time() { + let directory = tempdir().unwrap(); + let path = directory.path().join("recurring.agnd"); + let mut database = Database::open(&path).unwrap(); + let item = database.add_item("Weekly review").unwrap(); + database + .update_item( + item, + &ItemChanges { + when_at: Some(Some("2026-09-01T14:30:00+00:00".into())), + recurrence: Some("weekly".into()), + ..ItemChanges::default() + }, + ) + .unwrap(); + + database.toggle_done(&[item]).unwrap(); + + let items = database.items(&all_items(&database), "").unwrap(); + let next = items.iter().find(|candidate| candidate.id != item).unwrap(); + assert!( + next.when_at + .as_deref() + .unwrap() + .contains("2026-09-08T14:30:00") + ); +} + +#[test] +fn completed_recurring_ical_roundtrip_does_not_create_a_duplicate() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("source.agnd"); + let mut source = Database::open(&source_path).unwrap(); + let item = source.add_item("Prepare launch").unwrap(); + source + .update_item( + item, + &ItemChanges { + when_at: Some(Some("2026-09-01T14:30:00+00:00".into())), + recurrence: Some("every 2 weeks".into()), + ..ItemChanges::default() + }, + ) + .unwrap(); + source.toggle_done(&[item]).unwrap(); + + let export_path = directory.path().join("tasks.ics"); + source.export("ics", &export_path, None).unwrap(); + + let target_path = directory.path().join("target.agnd"); + let mut target = Database::open(&target_path).unwrap(); + assert_eq!(target.import_path(&export_path).unwrap(), 2); + assert_eq!(target.items(&all_items(&target), "").unwrap().len(), 2); +}