modularize app and database internals
- Extract editor, form, export, and schema modules - Add database workflow integration tests
This commit is contained in:
284
src/app.rs
284
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<ViewDef>,
|
||||
pub view_index: usize,
|
||||
pub items: Vec<Item>,
|
||||
pub selected: usize,
|
||||
pub scroll: usize,
|
||||
pub search: String,
|
||||
pub marked: HashSet<i64>,
|
||||
pub categories: Vec<Category>,
|
||||
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<Rect>,
|
||||
pub view_regions: Vec<Rect>,
|
||||
pub choice_regions: Vec<Rect>,
|
||||
pub macros: Vec<MacroDef>,
|
||||
pub macro_selected: usize,
|
||||
pub recording: Option<RecordingState>,
|
||||
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<ViewDef>,
|
||||
pub(crate) view_index: usize,
|
||||
pub(crate) items: Vec<Item>,
|
||||
pub(crate) selected: usize,
|
||||
pub(crate) scroll: usize,
|
||||
pub(crate) search: String,
|
||||
pub(crate) marked: HashSet<i64>,
|
||||
pub(crate) categories: Vec<Category>,
|
||||
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<Rect>,
|
||||
pub(crate) view_regions: Vec<Rect>,
|
||||
pub(crate) choice_regions: Vec<Rect>,
|
||||
pub(crate) macros: Vec<MacroDef>,
|
||||
pub(crate) macro_selected: usize,
|
||||
pub(crate) recording: Option<RecordingState>,
|
||||
pub(crate) macro_capture_key: bool,
|
||||
macro_runtime: Option<MacroRuntime>,
|
||||
macro_globals: std::collections::HashMap<String, String>,
|
||||
macro_return_mode: Option<Mode>,
|
||||
@@ -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<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>> {
|
||||
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::*;
|
||||
|
||||
82
src/app/editor.rs
Normal file
82
src/app/editor.rs
Normal 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
141
src/app/forms.rs
Normal 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
342
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<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T>
|
||||
@@ -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::<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>> {
|
||||
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<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(§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!(
|
||||
"<!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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn default_columns() -> Vec<ViewColumn> {
|
||||
[
|
||||
("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::*;
|
||||
|
||||
229
src/db/export.rs
Normal file
229
src/db/export.rs
Normal 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(§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', "<br>")
|
||||
}
|
||||
|
||||
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('"', "\"\""))
|
||||
}
|
||||
@@ -17,9 +17,7 @@ pub(super) fn parse_ical_date(value: &str) -> Option<String> {
|
||||
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::<Utc>::from_naive_utc_and_offset(date_time, Utc).to_rfc3339(),
|
||||
);
|
||||
return Some(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") {
|
||||
return Local
|
||||
|
||||
126
src/db/schema.rs
Normal file
126
src/db/schema.rs
Normal 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))
|
||||
}
|
||||
@@ -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))? {
|
||||
|
||||
78
src/ui.rs
78
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);
|
||||
|
||||
79
src/ui/theme.rs
Normal file
79
src/ui/theme.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
70
tests/database_workflows.rs
Normal file
70
tests/database_workflows.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user