modularize app and database internals

- Extract editor, form, export, and schema modules
- Add database workflow integration tests
This commit is contained in:
Chili Palmer
2026-08-19 22:20:15 +02:00
parent c8ad487ca1
commit b5b8ae0d55
11 changed files with 778 additions and 659 deletions

View File

@@ -22,6 +22,16 @@ use crate::{
preferences::AppPreferences, 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)] #[derive(Debug, Clone)]
pub enum InputKind { pub enum InputKind {
NewItem, NewItem,
@@ -132,32 +142,32 @@ pub enum ConfirmAction {
} }
pub struct App { pub struct App {
pub db: Database, pub(crate) db: Database,
pub path: PathBuf, pub(crate) path: PathBuf,
pub preferences: AppPreferences, pub(crate) preferences: AppPreferences,
pub preferences_path: PathBuf, pub(crate) preferences_path: PathBuf,
pub document_settings: DocumentSettings, pub(crate) document_settings: DocumentSettings,
pub views: Vec<ViewDef>, pub(crate) views: Vec<ViewDef>,
pub view_index: usize, pub(crate) view_index: usize,
pub items: Vec<Item>, pub(crate) items: Vec<Item>,
pub selected: usize, pub(crate) selected: usize,
pub scroll: usize, pub(crate) scroll: usize,
pub search: String, pub(crate) search: String,
pub marked: HashSet<i64>, pub(crate) marked: HashSet<i64>,
pub categories: Vec<Category>, pub(crate) categories: Vec<Category>,
pub category_selected: usize, pub(crate) category_selected: usize,
pub view_selected: usize, pub(crate) view_selected: usize,
pub mode: Mode, pub(crate) mode: Mode,
pub status: String, pub(crate) status: String,
pub should_quit: bool, pub(crate) should_quit: bool,
pub item_rows: Vec<(Rect, i64)>, pub(crate) item_rows: Vec<(Rect, i64)>,
pub command_regions: Vec<Rect>, pub(crate) command_regions: Vec<Rect>,
pub view_regions: Vec<Rect>, pub(crate) view_regions: Vec<Rect>,
pub choice_regions: Vec<Rect>, pub(crate) choice_regions: Vec<Rect>,
pub macros: Vec<MacroDef>, pub(crate) macros: Vec<MacroDef>,
pub macro_selected: usize, pub(crate) macro_selected: usize,
pub recording: Option<RecordingState>, pub(crate) recording: Option<RecordingState>,
pub macro_capture_key: bool, pub(crate) macro_capture_key: bool,
macro_runtime: Option<MacroRuntime>, macro_runtime: Option<MacroRuntime>,
macro_globals: std::collections::HashMap<String, String>, macro_globals: std::collections::HashMap<String, String>,
macro_return_mode: Option<Mode>, macro_return_mode: Option<Mode>,
@@ -248,6 +258,10 @@ impl App {
self.items.get(self.selected) self.items.get(self.selected)
} }
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn refresh(&mut self) -> Result<()> { pub fn refresh(&mut self) -> Result<()> {
let keep = self.selected_item().map(|i| i.id); let keep = self.selected_item().map(|i| i.id);
self.views = self.db.views()?; 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<bool> {
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<Vec<ChoiceOption>> {
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<Option<String>> { fn normalize_date(db: &Database, value: &str) -> Result<Option<String>> {
let s = value.trim(); let s = value.trim();
if s.is_empty() { 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

82
src/app/editor.rs Normal file
View File

@@ -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)
};
}

141
src/app/forms.rs Normal file
View File

@@ -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<bool> {
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<Vec<ChoiceOption>> {
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("")
}

342
src/db.rs
View File

@@ -6,21 +6,24 @@ use std::{
}; };
use anyhow::{Context, Result, bail}; 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 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::filter;
use crate::model::{ use crate::model::{
Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef,
RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection, RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection,
}; };
mod presets; mod export;
mod import; mod import;
mod presets;
mod schema;
pub use presets::Preset; 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}; use self::import::{IcalRecord, ical_unescape, parse_ical_date, rrule_to_recurrence};
fn enum_column<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T> fn enum_column<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T>
@@ -60,7 +63,7 @@ impl Database {
path: path.to_owned(), path: path.to_owned(),
new_document: !existed, new_document: !existed,
}; };
db.migrate()?; schema::migrate(&db.conn)?;
db.seed_defaults()?; db.seed_defaults()?;
if existed && db.document_settings()?.backup_on_open { if existed && db.document_settings()?.backup_on_open {
db.backup_now()?; db.backup_now()?;
@@ -68,129 +71,6 @@ impl Database {
Ok(db) 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::<rusqlite::Result<Vec<_>>>()?;
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::<rusqlite::Result<Vec<_>>>()?;
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<Vec<MacroDef>> { pub fn macros(&self) -> Result<Vec<MacroDef>> {
let mut statement = self let mut statement = self
.conn .conn
@@ -1486,198 +1366,6 @@ fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> O
.earliest() .earliest()
.map(|date_time| date_time.to_rfc3339()) .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<String> {
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::<u32>().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::<Vec<_>>()
.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<String> {
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(&section.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!(
"<!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>",
html(&view.name),
html(&view.name)
);
for (heading, group) in report_groups(view, items) {
if !heading.is_empty() {
out.push_str(&format!("<h2>{}</h2>", html(&heading)));
}
out.push_str("<table><thead><tr>");
for column in &view.columns {
out.push_str(&format!("<th>{}</th>", html(&column.heading)));
}
out.push_str("</tr></thead><tbody>");
for item in group {
out.push_str("<tr>");
for column in &view.columns {
out.push_str(&format!(
"<td>{}</td>",
html(&report_value(&column.field, item)).replace('\n', "<br>")
));
}
out.push_str("</tr>");
}
out.push_str("</tbody></table>");
}
out.push_str("</body></html>\n");
out
}
fn markdown(value: &str) -> String {
value.replace('|', "\\|").replace('\n', "<br>")
}
fn html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn default_columns() -> Vec<ViewColumn> { fn default_columns() -> Vec<ViewColumn> {
[ [
("item", "Items", 48), ("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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

229
src/db/export.rs Normal file
View File

@@ -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::<Vec<_>>()
.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<String> {
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<String> {
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::<u32>().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!(
"<!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>",
html(&view.name),
html(&view.name)
);
for (heading, group) in report_groups(view, items) {
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("</tr></thead><tbody>");
for item in group {
output.push_str("<tr>");
for column in &view.columns {
output.push_str(&format!(
"<td>{}</td>",
html(&report_value(&column.field, item)).replace('\n', "<br>")
));
}
output.push_str("</tr>");
}
output.push_str("</tbody></table>");
}
output.push_str("</body></html>\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(&section.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', "<br>")
}
fn html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
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('"', "\"\""))
}

View File

@@ -17,9 +17,7 @@ pub(super) fn parse_ical_date(value: &str) -> Option<String> {
return Some(date_time.to_rfc3339()); return Some(date_time.to_rfc3339());
} }
if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") { if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") {
return Some( return Some(DateTime::<Utc>::from_naive_utc_and_offset(date_time, Utc).to_rfc3339());
DateTime::<Utc>::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") { if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") {
return Local return Local

126
src/db/schema.rs Normal file
View File

@@ -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<bool> {
let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
let names = statement
.query_map([], |row| row.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(names.iter().any(|name| name == column))
}

View File

@@ -80,7 +80,7 @@ fn run_tui(mut app: App) -> Result<()> {
loop { loop {
app.tick()?; app.tick()?;
terminal.draw(|f| ui::draw(f, &mut app))?; terminal.draw(|f| ui::draw(f, &mut app))?;
if app.should_quit { if app.should_quit() {
break; break;
} }
if event::poll(Duration::from_millis(250))? { if event::poll(Duration::from_millis(250))? {

View File

@@ -16,83 +16,9 @@ use crate::{
model::{Aggregate, Item, ViewColumn, ViewKind}, model::{Aggregate, Item, ViewColumn, ViewKind},
}; };
#[derive(Clone, Copy)] mod theme;
struct Palette {
primary: Color,
accent: Color,
canvas: Color,
canvas_heading: Color,
selection: Color,
selection_foreground: Color,
foreground: Color,
}
fn palette(theme: &str) -> Palette { use theme::{Palette, 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,
)
}
pub fn draw(frame: &mut Frame<'_>, app: &mut App) { pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let colors = palette(&app.preferences.theme); let colors = palette(&app.preferences.theme);

79
src/ui/theme.rs Normal file
View File

@@ -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,
)
}

View File

@@ -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);
}