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,
|
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
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 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(§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> {
|
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
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());
|
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
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 {
|
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))? {
|
||||||
|
|||||||
78
src/ui.rs
78
src/ui.rs
@@ -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
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