diff --git a/PRESETS.md b/PRESETS.md new file mode 100644 index 0000000..9450a8d --- /dev/null +++ b/PRESETS.md @@ -0,0 +1,189 @@ +# Rogue Agenda presets + +Presets create complete example `.agnd` workspaces. They are intended both as +useful starting points and as inspectable examples of how categories, hierarchy, +saved views, sections, columns, rules, notes, dates, numeric values, and macros +fit together. + +All bundled records, organizations, people, routes, recipes, and wording are +original fictional demonstration content. The presets reimagine documented +Agenda workflows without copying or requiring legacy `.AG` files. + +## Creating a preset document + +Pass one preset name when creating a document: + +```sh +rogue-agenda sales.agnd --preset accounts +rogue-agenda course-notes.agnd --preset study +rogue-agenda week.agnd --preset planner +rogue-agenda kitchen.agnd --preset recipes +rogue-agenda cycling.agnd --preset rides +rogue-agenda team.agnd --preset people +``` + +From the source tree, prefix the same arguments with `cargo run --`: + +```sh +cargo run -- learning.agnd --preset study +``` + +A preset deliberately works only when the destination is new. Rogue Agenda +refuses to replace an existing SQLite document, even if that document has no +items. Choose a path that does not exist if you want a fresh copy. `--preset` +and the smaller `--demo` initializer cannot be used together. + +Dates in sample records are relative to the day the document is created, so +upcoming, overdue, Datebook, and review views are immediately meaningful. Preset +documents remain ordinary editable `.agnd` files: rename or remove categories, +rewrite rules and macros, delete all sample records, and evolve the workspace as +you would any other Rogue Agenda document. + +## `accounts` + +Account Desk is a broad relationship and sales-support workspace. It is the most +complete general demonstration of Agenda's single-copy information model: an +issue or activity can appear under an account, workflow stage, status report, +and time-based view without being duplicated. + +It provides: + +- Account and contact records, with fictional Northstar Labs and Harbor Books + examples. +- A mutually exclusive opportunity pipeline: Prospect, Qualified, Proposal, + and Customer. +- Calls, meetings, and follow-ups under an Activities hierarchy. +- Open, waiting, and resolved issue states. +- Numeric expense records with a summed Amount column. +- A sectioned status report divided into urgent and routine material. +- Filing rules that collect priority-one work into the status report and detect + follow-up wording. +- `captureaccount` and `capturecall` prompt-driven macros. + +The principal views are Accounts, Pipeline, Open Issues, Calls and Meetings, +Expenses, and Status Report. This preset is a good foundation for lightweight +CRM, client service, fundraising, vendor management, or case tracking. + +## `study` + +Study Deck is inspired by the idea of using Agenda items and notes as a learning +system. The item text is a question, its multiline note is the answer, `When` is +the next review date, and the numeric value is a visible learning-cycle number. + +It provides: + +- Computing, Languages, and History example decks. +- Mutually exclusive New, Learning, and Review stages. +- Due Review, New Cards, All Cards, Deck Browser, and Datebook views. +- A sectioned deck browser that shows the same cards by subject. +- Cycle values and relative review dates on the included cards. +- A `capturecard` macro that asks for a question and answer, creates the card, + and writes the answer into its note. +- A `revealanswer` macro that presents the selected question and then opens its + note. Run it from the Macro Manager while a card is selected. + +Rogue Agenda does not silently grade a response. Move cards between learning +stages and set their next review dates according to the method you prefer. The +preset intentionally exposes those mechanics so they can be customized. + +## `planner` + +Activities Planner expands the small default document into a worked example of +personal planning. It demonstrates how users can grow Rogue Agenda gradually +instead of adopting a rigid methodology. + +It provides: + +- Next Actions with Calls, Meetings, and Errands beneath the activity hierarchy. +- Website Refresh and Studio Move example projects. +- Work, Home, and Personal areas. +- At Computer, On Phone, and Out and About contexts. +- Today, This Week, Next Actions, Projects, Calls and Meetings, Ideas, and + Datebook views. +- Rules that promote priority-one and priority-two open work to Next Actions and + recognize dated calls or meetings as scheduled activities. +- A recurring weekly meeting and a completed action to demonstrate lifecycle + views. +- `quickcapture` and `captureidea` macros. + +This is the best preset for learning how the ordinary default setup can be +extended while preserving free-form capture. + +## `recipes` + +Recipe Box treats each recipe as a single item with ingredients and method in +its note. Categories provide multiple browsing paths without maintaining +separate copies of a recipe. + +It provides: + +- Course categories for Breakfast, Main Dishes, Desserts, and Drinks. +- Cuisine, dietary, ingredient, Quick, and Favorites classifications. +- A numeric Servings value shown alongside each recipe. +- Recipe Box, Favorites, Main Dishes, Quick Meals, Vegetarian, and sectioned By + Course views. +- Five small original recipes covering different courses and dietary tags. +- A rule that notices cooking-time wording in notes and files recipes as Quick. +- A `newrecipe` macro that creates a recipe and lays out an Ingredients, Method, + and Notes template in the note editor. +- A `cookingnote` macro for capturing observations that do not yet belong to a + formal recipe. + +The ingredient categories are intentionally small. Add frequently used +ingredients as children of Ingredients to turn the preset into a deeper index. + +## `rides` + +Bicycle Log demonstrates numeric reporting and several overlapping category +dimensions. Ride distance is stored as the item's numeric value in kilometres. + +It provides: + +- Commute, Training, and Leisure ride types. +- Road, Touring, and City bicycles. +- Clear, Cloudy, and Rain conditions plus named Routes. +- Ride Log and Training totals, a Long Rides filter, and a sectioned By Bicycle + report. +- Past and upcoming rides with original notes, route details, and distances. +- A separate Maintenance view with dated bicycle service tasks. +- Rules that recognize ride and maintenance wording. +- `logride` and `servicereminder` capture macros. + +Use `F6` on a newly captured ride to record its numeric distance and date, then +assign bicycle, ride type, route, and weather with `F3`. + +## `people` + +People Manager is a lightweight development and team-information workspace. It +is meant for supportive planning and review preparation, not confidential HR +records or formal performance scoring. + +It provides: + +- People profiles organized by Product, Operations, and Community teams. +- Mutually exclusive Active, Candidate, and Alumni states. +- Separate records for goals, reviews, development actions, achievements, and + hiring activity. +- People Directory, Upcoming Reviews, Goals, Development, Candidates, and + Achievements views. +- A numeric Progress example on a dated goal. +- Filing rules for `Person:`, `Review:`, and `Goal:` capture conventions. +- `addperson` and `addgoal` prompt-driven macros. + +The sample content is deliberately non-sensitive. Before using this preset for +real people, decide what information is appropriate to store and who can access +the `.agnd` file and its backups. + +## Presets versus `--demo` + +`--demo` adds only a handful of representative items to an otherwise default +document. It remains useful for a quick interface check: + +```sh +rogue-agenda scratch.agnd --demo +``` + +Presets replace the default category and view definitions in a new document with +a coherent domain-specific workspace and include tailored macros, rules, and +sample records. Use a preset when you want to explore how a substantial Agenda +application is assembled. diff --git a/PROJECT.md b/PROJECT.md index 59f8a52..746aa05 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -112,6 +112,10 @@ Research URLs: syntax highlighting for commands, labels, variables, values, comments, delimiters, and malformed source. - [x] Demo data, CLI help, README, MIT license, and unit/integration tests. +- [x] Six clean-room, version-tagged preset workspaces selected with `--preset`: + Accounts, Study, Planner, Recipes, Rides, and People. Each safely initializes + only a new document and supplies tailored categories, views, sections, rules, + macros, relative-date sample records, and user documentation in `PRESETS.md`. ## Supported with a modern interpretation diff --git a/README.md b/README.md index 1d12c48..e16a696 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ database with the `.agnd` extension. persistent variables, branches, loops, calls, prompts and menus, AUTOEXEC, key bindings, and a Learn recorder. The responsive macro editor adds syntax highlighting and key capture without sanding away the unusual original language. +- Six complete clean-room presets for account work, study cards, personal + planning, recipes, bicycle logs, and people development. Each combines its own + categories, views, rules, macros, and original sample records. ## Build and run @@ -60,6 +63,18 @@ initialized with useful categories and saved views. To explore a populated file: cargo run -- demo.agnd --demo ``` +For a richer example, initialize a new document with one of the six presets: + +```sh +cargo run -- sales.agnd --preset accounts +cargo run -- learning.agnd --preset study +``` + +The available names are `accounts`, `study`, `planner`, `recipes`, `rides`, and +`people`. Presets only initialize new document paths and never replace an existing +file. See [PRESETS.md](PRESETS.md) for the supplied views, rules, macros, and demo +content in every workspace. + ### Install for use from any directory From the Rogue Agenda source directory, install the release binary under your @@ -82,6 +97,7 @@ After opening a new shell, Rogue Agenda can be launched from anywhere: ```sh rogue-agenda notes.agnd rogue-agenda demo.agnd --demo +rogue-agenda week.agnd --preset planner ``` Noninteractive transfer commands are also available: diff --git a/src/db.rs b/src/db.rs index b6fb141..007cd93 100644 --- a/src/db.rs +++ b/src/db.rs @@ -14,9 +14,14 @@ use crate::model::{ }; use crate::parser::{extract_when_configured, next_occurrence}; +mod presets; + +pub use presets::Preset; + pub struct Database { conn: Connection, path: PathBuf, + new_document: bool, } impl Database { @@ -30,6 +35,7 @@ impl Database { let mut db = Self { conn, path: path.to_owned(), + new_document: !existed, }; db.migrate()?; db.seed_defaults()?; diff --git a/src/db/presets.rs b/src/db/presets.rs new file mode 100644 index 0000000..9d2eccb --- /dev/null +++ b/src/db/presets.rs @@ -0,0 +1,1381 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result, bail}; +use chrono::{Duration, Local, TimeZone}; +use clap::ValueEnum; +use rusqlite::params; + +use super::{Database, parse_action_spec, parse_columns_spec, parse_sections_spec}; +use crate::{ + filter, + macro_lang::{MacroRuntime, macro_name, parse_key_binding}, + model::MacroDef, +}; + +const PRESET_VERSION: &str = "1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Preset { + Accounts, + Study, + Planner, + Recipes, + Rides, + People, +} + +impl Preset { + pub const fn name(self) -> &'static str { + match self { + Self::Accounts => "accounts", + Self::Study => "study", + Self::Planner => "planner", + Self::Recipes => "recipes", + Self::Rides => "rides", + Self::People => "people", + } + } +} + +struct PresetSpec { + title: &'static str, + description: &'static str, + categories: Vec, + views: Vec, + rules: Vec, + macros: Vec, + items: Vec, +} + +struct CategorySpec { + name: &'static str, + parent: Option<&'static str>, + kind: &'static str, + match_text: &'static str, + exclusive: bool, +} + +const fn category( + name: &'static str, + parent: Option<&'static str>, + kind: &'static str, + match_text: &'static str, + exclusive: bool, +) -> CategorySpec { + CategorySpec { + name, + parent, + kind, + match_text, + exclusive, + } +} + +struct ViewSpec { + name: &'static str, + kind: &'static str, + filter_value: &'static str, + sort_key: &'static str, + show_done: bool, + filter_expr: &'static str, + columns: &'static str, + sections: &'static str, +} + +impl ViewSpec { + const fn new(name: &'static str) -> Self { + Self { + name, + kind: "list", + filter_value: "", + sort_key: "manual", + show_done: true, + filter_expr: "", + columns: "item:48:Items,categories:27:Categories,when:20:When,priority:5:P", + sections: "", + } + } + + const fn category(mut self, name: &'static str) -> Self { + self.kind = "category"; + self.filter_value = name; + self + } + + const fn kind(mut self, kind: &'static str, value: &'static str) -> Self { + self.kind = kind; + self.filter_value = value; + self + } + + const fn sort(mut self, sort_key: &'static str) -> Self { + self.sort_key = sort_key; + self + } + + const fn hide_done(mut self) -> Self { + self.show_done = false; + self + } + + const fn filter(mut self, expression: &'static str) -> Self { + self.filter_expr = expression; + self + } + + const fn columns(mut self, columns: &'static str) -> Self { + self.columns = columns; + self + } + + const fn sections(mut self, sections: &'static str) -> Self { + self.sections = sections; + self + } +} + +struct RuleSpec { + category: &'static str, + condition: &'static str, + action: &'static str, +} + +const fn rule(category: &'static str, condition: &'static str, action: &'static str) -> RuleSpec { + RuleSpec { + category, + condition, + action, + } +} + +struct MacroSpec { + name: &'static str, + source: &'static str, + key_binding: &'static str, +} + +const fn agenda_macro(name: &'static str, source: &'static str) -> MacroSpec { + MacroSpec { + name, + source, + key_binding: "", + } +} + +struct ItemSpec { + text: &'static str, + note: &'static str, + priority: i64, + when: Option<(i64, u32, u32)>, + numeric_value: Option, + recurrence: &'static str, + done: bool, + categories: &'static [&'static str], +} + +impl ItemSpec { + const fn new( + text: &'static str, + note: &'static str, + categories: &'static [&'static str], + ) -> Self { + Self { + text, + note, + priority: 3, + when: None, + numeric_value: None, + recurrence: "", + done: false, + categories, + } + } + + const fn priority(mut self, priority: i64) -> Self { + self.priority = priority; + self + } + + const fn when(mut self, days: i64, hour: u32, minute: u32) -> Self { + self.when = Some((days, hour, minute)); + self + } + + const fn value(mut self, value: f64) -> Self { + self.numeric_value = Some(value); + self + } + + const fn recurring(mut self, recurrence: &'static str) -> Self { + self.recurrence = recurrence; + self + } + + const fn done(mut self) -> Self { + self.done = true; + self + } +} + +impl Database { + pub fn apply_preset(&mut self, preset: Preset) -> Result<()> { + if !self.new_document { + bail!("--preset can only initialize a new document; choose a path that does not exist"); + } + let spec = preset_spec(preset); + validate_spec(&spec)?; + let now = Local::now().to_rfc3339(); + let tx = self.conn.transaction()?; + tx.execute_batch( + "DELETE FROM macro_variables; + DELETE FROM macros; + DELETE FROM category_rules; + DELETE FROM view_sections; + DELETE FROM view_columns; + DELETE FROM views; + DELETE FROM dependencies; + DELETE FROM item_categories; + DELETE FROM items; + DELETE FROM categories;", + )?; + + let mut category_ids = HashMap::new(); + for (order, category) in spec.categories.iter().enumerate() { + let parent_id = category + .parent + .map(|parent| { + category_ids + .get(parent) + .copied() + .with_context(|| format!("preset category parent {parent:?} is missing")) + }) + .transpose()?; + tx.execute( + "INSERT INTO categories(name,parent_id,kind,match_text,exclusive,sort_order) + VALUES(?1,?2,?3,?4,?5,?6)", + params![ + category.name, + parent_id, + category.kind, + category.match_text, + category.exclusive as i64, + order as i64 + ], + )?; + category_ids.insert(category.name, tx.last_insert_rowid()); + } + + for (order, view) in spec.views.iter().enumerate() { + tx.execute( + "INSERT INTO views(name,kind,filter_value,sort_key,show_done,sort_order,filter_expr) + VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + view.name, + view.kind, + view.filter_value, + view.sort_key, + view.show_done as i64, + order as i64, + view.filter_expr + ], + )?; + let view_id = tx.last_insert_rowid(); + for (column_order, column) in parse_columns_spec(view.columns)?.iter().enumerate() { + tx.execute( + "INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order) + VALUES(?1,?2,?3,?4,?5,?6)", + params![ + view_id, + column.field, + column.heading, + column.width, + column.aggregate, + column_order as i64 + ], + )?; + } + for (section_order, section) in parse_sections_spec(view.sections)?.iter().enumerate() { + tx.execute( + "INSERT INTO view_sections(view_id,heading,filter_expr,collapsed,sort_order) + VALUES(?1,?2,?3,?4,?5)", + params![ + view_id, + section.heading, + section.filter_expr, + section.collapsed as i64, + section_order as i64 + ], + )?; + } + } + + for (order, preset_rule) in spec.rules.iter().enumerate() { + let category_id = category_ids[preset_rule.category]; + let (action_kind, action_value) = parse_action_spec(preset_rule.action)?; + tx.execute( + "INSERT INTO category_rules(category_id,condition_expr,action_kind,action_value,enabled,sort_order) + VALUES(?1,?2,?3,?4,1,?5)", + params![ + category_id, + preset_rule.condition, + action_kind, + action_value, + order as i64 + ], + )?; + } + + for (order, preset_macro) in spec.macros.iter().enumerate() { + tx.execute( + "INSERT INTO macros(name,source,key_binding,sort_order) VALUES(?1,?2,?3,?4)", + params![ + preset_macro.name, + preset_macro.source, + preset_macro.key_binding, + order as i64 + ], + )?; + } + + for (order, item) in spec.items.iter().enumerate() { + let when_at = item + .when + .map(|(days, hour, minute)| preset_date(days, hour, minute)) + .transpose()?; + let done_at = item.done.then(|| now.clone()); + tx.execute( + "INSERT INTO items(text,note,priority,when_at,done_at,numeric_value,recurrence,created_at,updated_at,sort_order) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,?9)", + params![ + item.text, + item.note, + item.priority, + when_at, + done_at, + item.numeric_value, + item.recurrence, + now, + order as i64 + ], + )?; + let item_id = tx.last_insert_rowid(); + for category in item.categories { + tx.execute( + "INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'explicit')", + params![item_id, category_ids[category]], + )?; + } + } + + for (key, value) in [ + ("title", spec.title), + ("document.description", spec.description), + ("preset.name", preset.name()), + ("preset.version", PRESET_VERSION), + ] { + tx.execute( + "INSERT INTO meta(key,value) VALUES(?1,?2) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + params![key, value], + )?; + } + tx.commit()?; + self.new_document = false; + Ok(()) + } +} + +fn preset_date(days: i64, hour: u32, minute: u32) -> Result { + let date = Local::now().date_naive() + Duration::days(days); + let naive = date + .and_hms_opt(hour, minute, 0) + .context("preset contains an invalid time")?; + Local + .from_local_datetime(&naive) + .earliest() + .context("preset date does not exist in the local time zone") + .map(|date_time| date_time.to_rfc3339()) +} + +fn validate_spec(spec: &PresetSpec) -> Result<()> { + let mut categories = HashSet::new(); + for category in &spec.categories { + if !categories.insert(category.name) { + bail!("preset has duplicate category {:?}", category.name); + } + if category + .parent + .is_some_and(|parent| !categories.contains(parent)) + { + bail!("preset category parent must appear before its children"); + } + if !matches!(category.kind, "standard" | "date" | "numeric") { + bail!("preset has unsupported category kind {:?}", category.kind); + } + } + + let mut view_names = HashSet::new(); + for view in &spec.views { + if !view_names.insert(view.name) { + bail!("preset has duplicate view {:?}", view.name); + } + filter::parse(view.filter_expr)?; + parse_columns_spec(view.columns)?; + parse_sections_spec(view.sections)?; + } + + for preset_rule in &spec.rules { + if !categories.contains(preset_rule.category) { + bail!("preset rule category {:?} is missing", preset_rule.category); + } + filter::parse(preset_rule.condition)?; + parse_action_spec(preset_rule.action)?; + } + + let definitions = spec + .macros + .iter() + .enumerate() + .map(|(index, preset_macro)| { + let parsed_name = macro_name(preset_macro.source)?; + if !parsed_name.eq_ignore_ascii_case(preset_macro.name) { + bail!("preset macro name does not match its source"); + } + if !preset_macro.key_binding.is_empty() { + parse_key_binding(preset_macro.key_binding)?; + } + Ok(MacroDef { + id: index as i64, + name: preset_macro.name.into(), + source: preset_macro.source.into(), + key_binding: preset_macro.key_binding.into(), + }) + }) + .collect::>>()?; + let mut macro_names = HashSet::new(); + for definition in &definitions { + if !macro_names.insert(definition.name.to_lowercase()) { + bail!("preset has duplicate macro {:?}", definition.name); + } + MacroRuntime::new(&definition.name, &definitions, HashMap::new())?; + } + + for item in &spec.items { + if item.text.trim().is_empty() { + bail!("preset item text cannot be empty"); + } + if !(1..=5).contains(&item.priority) { + bail!("preset item priority is outside 1 through 5"); + } + for category in item.categories { + if !categories.contains(category) { + bail!("preset item category {category:?} is missing"); + } + } + } + Ok(()) +} + +fn preset_spec(preset: Preset) -> PresetSpec { + match preset { + Preset::Accounts => accounts(), + Preset::Study => study(), + Preset::Planner => planner(), + Preset::Recipes => recipes(), + Preset::Rides => rides(), + Preset::People => people(), + } +} + +fn common_tail() -> Vec { + vec![ + ViewSpec::new("Recently Done") + .kind("done", "14") + .sort("done"), + ViewSpec::new("Trash").kind("trash", "").sort("updated"), + ] +} + +fn accounts() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("All Items").sort("updated"), + ViewSpec::new("Accounts") + .category("Accounts") + .columns("item:36:Account,note:42:Profile,categories:28:Filed under"), + ViewSpec::new("Pipeline") + .category("Pipeline") + .sort("priority") + .hide_done() + .columns("item:42:Opportunity,categories:30:Stage,when:20:Next action,priority:5:P") + .sections("Prospects|category=Prospect;Qualified|category=Qualified;Proposals|category=Proposal;Customers|category=Customer"), + ViewSpec::new("Open Issues") + .category("Issues") + .sort("priority") + .hide_done() + .filter("not category=Resolved") + .columns("item:48:Issue,categories:28:Account / state,when:20:Due,priority:5:P"), + ViewSpec::new("Calls and Meetings") + .category("Activities") + .sort("when") + .hide_done() + .columns("item:48:Activity,categories:28:Account / type,when:20:When,priority:5:P"), + ViewSpec::new("Expenses") + .category("Expenses") + .sort("updated") + .columns("item:42:Expense,categories:28:Account,value:14:Amount:sum,note:30:Details"), + ViewSpec::new("Status Report") + .category("Status Report") + .sort("priority") + .columns("item:48:Status item,categories:28:Account,when:20:When,priority:5:P") + .sections("Urgent|priority<=2;Routine|priority>2"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — Account Desk", + description: "A relationship workspace for accounts, opportunities, issues, activities, expenses, and status reporting.", + categories: vec![ + category("Accounts", None, "standard", "account:", false), + category( + "Northstar Labs", + Some("Accounts"), + "standard", + "northstar", + false, + ), + category( + "Harbor Books", + Some("Accounts"), + "standard", + "harbor", + false, + ), + category("Contacts", None, "standard", "contact:", false), + category("Pipeline", None, "standard", "opportunity:", false), + category("Prospect", Some("Pipeline"), "standard", "", true), + category("Qualified", Some("Pipeline"), "standard", "", true), + category("Proposal", Some("Pipeline"), "standard", "proposal", true), + category("Customer", Some("Pipeline"), "standard", "", true), + category("Activities", None, "standard", "", false), + category("Calls", Some("Activities"), "standard", "call,phone", false), + category( + "Meetings", + Some("Activities"), + "standard", + "meet,meeting", + false, + ), + category( + "Follow-ups", + Some("Activities"), + "standard", + "follow up,follow-up", + false, + ), + category("Issues", None, "standard", "issue:", false), + category("Open Issue", Some("Issues"), "standard", "", true), + category("Waiting", Some("Issues"), "standard", "waiting", true), + category("Resolved", Some("Issues"), "standard", "resolved", true), + category("Expenses", None, "numeric", "expense:", false), + category("Status Report", None, "standard", "status:", false), + ], + views, + rules: vec![ + rule("Status Report", "priority=1 and open", "assign:"), + rule("Follow-ups", "text~follow-up", "assign:"), + ], + macros: vec![ + agenda_macro( + "captureaccount", + "{captureaccount}\n{inputtext;Company or account name;%name}\nn{type;Account: }{type;%name}{enter}", + ), + agenda_macro( + "capturecall", + "{capturecall}\n{inputtext;Who should you call?;%name}\nn{type;Call }{type;%name}{enter}", + ), + ], + items: vec![ + ItemSpec::new( + "Account: Northstar Labs", + "Renewable-energy analytics team. Primary contact: Mina Chen. Current objective: replace the weekly spreadsheet handoff.", + &["Northstar Labs", "Customer"], + ), + ItemSpec::new( + "Contact: Mina Chen — Operations Director", + "Northstar Labs. Prefers concise email follow-ups after calls.", + &["Contacts", "Northstar Labs"], + ), + ItemSpec::new( + "Opportunity: Harbor Books inventory workshop", + "Discovery found duplicate stock counts across three locations.", + &["Harbor Books", "Qualified"], + ) + .priority(2) + .when(3, 10, 0), + ItemSpec::new( + "Call Mina about pilot measurements", + "Confirm which dashboard measures belong in the two-week pilot.", + &["Northstar Labs", "Calls"], + ) + .priority(1) + .when(1, 14, 0), + ItemSpec::new( + "Issue: Harbor export omits back-ordered titles", + "Waiting for a sanitized export that reproduces the missing rows.", + &["Harbor Books", "Waiting"], + ) + .priority(1) + .when(2, 9, 30), + ItemSpec::new( + "Expense: train to Northstar workshop", + "Return fare for the discovery workshop.", + &["Northstar Labs", "Expenses"], + ) + .value(46.80) + .done(), + ItemSpec::new( + "Status: Northstar pilot scope approved", + "The pilot will cover intake latency, exception volume, and operator time.", + &["Northstar Labs", "Status Report"], + ) + .priority(2), + ], + } +} + +fn study() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("All Items").sort("updated"), + ViewSpec::new("Due Review") + .sort("when") + .hide_done() + .filter("category=Cards and (overdue or due=7d)") + .columns("item:55:Question,categories:28:Deck / stage,when:20:Review,value:8:Cycle"), + ViewSpec::new("New Cards") + .category("New") + .sort("manual") + .hide_done() + .columns("item:60:Question,categories:30:Deck,note:36:Answer"), + ViewSpec::new("All Cards") + .category("Cards") + .sort("when") + .columns("item:55:Question,categories:28:Deck / stage,when:20:Review,value:8:Cycle"), + ViewSpec::new("Deck Browser") + .category("Cards") + .columns("item:52:Question,note:42:Answer,categories:28:Deck") + .sections("Computing|category=Computing;Languages|category=Languages;History|category=History"), + ViewSpec::new("Datebook").kind("datebook", "30").sort("when"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — Study Deck", + description: "A note-backed flashcard workspace with decks, learning stages, review dates, and cycle values.", + categories: vec![ + category("Cards", None, "standard", "q:", false), + category("Decks", None, "standard", "", false), + category("Computing", Some("Decks"), "standard", "", false), + category("Languages", Some("Decks"), "standard", "", false), + category("History", Some("Decks"), "standard", "", false), + category("Learning Stage", None, "standard", "", false), + category("New", Some("Learning Stage"), "standard", "", true), + category("Learning", Some("Learning Stage"), "standard", "", true), + category("Review", Some("Learning Stage"), "standard", "", true), + category("Review Date", None, "date", "", false), + category("Cycle", None, "numeric", "", false), + ], + views, + rules: vec![rule("Cards", "text~q:", "assign:")], + macros: vec![ + agenda_macro( + "capturecard", + "{capturecard}\n{inputtext;Question;%question}\n{inputtext;Answer;%answer}\nn{type;Q: }{type;%question}{enter}{F5}{type;%answer}{F5}", + ), + agenda_macro( + "revealanswer", + "{revealanswer}\n{largebox;Question;#HIGHLIGHT_VALUE}\n{F5}", + ), + ], + items: vec![ + ItemSpec::new( + "Q: What does ownership prevent in Rust?", + "It prevents multiple parts of a program from mutating or freeing the same value without explicit coordination.", + &["Cards", "Computing", "Review", "Cycle"], + ) + .when(0, 9, 0) + .value(3.0), + ItemSpec::new( + "Q: Which SQL clause filters grouped results?", + "HAVING filters after grouping; WHERE filters rows before grouping.", + &["Cards", "Computing", "Learning", "Cycle"], + ) + .when(1, 9, 0) + .value(2.0), + ItemSpec::new( + "Q: What does ‘bonjour’ mean?", + "Hello or good day in French.", + &["Cards", "Languages", "New", "Cycle"], + ) + .value(0.0), + ItemSpec::new( + "Q: In which year did the first transatlantic telegraph cable succeed?", + "A durable transatlantic cable entered service in 1866.", + &["Cards", "History", "Review", "Cycle"], + ) + .when(5, 9, 0) + .value(5.0), + ItemSpec::new( + "Q: What is spaced repetition trying to optimize?", + "Review near the point of forgetting so that durable recall grows with less total study time.", + &["Cards", "New", "Cycle"], + ) + .value(0.0), + ], + } +} + +fn planner() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("All Items").sort("manual"), + ViewSpec::new("Today") + .kind("upcoming", "1") + .sort("when") + .hide_done(), + ViewSpec::new("This Week") + .kind("upcoming", "7") + .sort("when") + .hide_done() + .columns( + "item:50:Commitment,categories:28:Project / context,when:20:When,priority:5:P", + ), + ViewSpec::new("Next Actions") + .category("Next Actions") + .sort("priority") + .hide_done() + .columns("item:52:Action,categories:30:Project / context,when:20:When,priority:5:P"), + ViewSpec::new("Projects") + .category("Projects") + .sort("priority") + .hide_done(), + ViewSpec::new("Calls and Meetings") + .category("Scheduled Activity") + .sort("when") + .hide_done(), + ViewSpec::new("Ideas") + .category("Ideas") + .columns("item:58:Idea,note:48:Notes,categories:25:Area"), + ViewSpec::new("Datebook") + .kind("datebook", "30") + .sort("when"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — Activities Planner", + description: "An extensible daily planner for projects, next actions, scheduled activities, contexts, and ideas.", + categories: vec![ + category( + "Next Actions", + None, + "standard", + "todo,call,send,buy,review", + false, + ), + category("Scheduled Activity", None, "standard", "", false), + category( + "Calls", + Some("Scheduled Activity"), + "standard", + "call,phone", + false, + ), + category( + "Meetings", + Some("Scheduled Activity"), + "standard", + "meet,meeting,appointment", + false, + ), + category( + "Errands", + Some("Next Actions"), + "standard", + "buy,pick up,errand", + false, + ), + category("Projects", None, "standard", "project:", false), + category( + "Website Refresh", + Some("Projects"), + "standard", + "website", + false, + ), + category("Studio Move", Some("Projects"), "standard", "studio", false), + category("Areas", None, "standard", "", false), + category("Work", Some("Areas"), "standard", "", false), + category("Home", Some("Areas"), "standard", "", false), + category("Personal", Some("Areas"), "standard", "", false), + category("Contexts", None, "standard", "", false), + category( + "At Computer", + Some("Contexts"), + "standard", + "email,website,draft", + false, + ), + category( + "On Phone", + Some("Contexts"), + "standard", + "call,phone", + false, + ), + category( + "Out and About", + Some("Contexts"), + "standard", + "buy,pick up", + false, + ), + category("Ideas", None, "standard", "idea:,maybe", false), + category("When", None, "date", "", false), + category("Priority", None, "numeric", "", false), + ], + views, + rules: vec![ + rule("Next Actions", "priority<=2 and open", "assign:"), + rule( + "Scheduled Activity", + "dated and (category=Calls or category=Meetings)", + "assign:", + ), + ], + macros: vec![ + agenda_macro( + "quickcapture", + "{quickcapture}\n{inputtext;Capture a thought or action;%item}\nn{type;%item}{enter}", + ), + agenda_macro( + "captureidea", + "{captureidea}\n{inputtext;What is the idea?;%idea}\nn{type;Idea: }{type;%idea}{enter}", + ), + ], + items: vec![ + ItemSpec::new( + "Project: Website refresh", + "Outcome: a fast, accessible portfolio with a simpler inquiry path.", + &["Website Refresh", "Work"], + ) + .priority(2), + ItemSpec::new( + "Draft website content inventory", + "List every current page and decide keep, rewrite, merge, or remove.", + &["Website Refresh", "Next Actions", "At Computer"], + ) + .priority(1) + .when(1, 9, 0), + ItemSpec::new( + "Call movers for a revised studio estimate", + "Ask whether packing crates and weekend delivery are included.", + &["Studio Move", "Calls", "On Phone"], + ) + .priority(2) + .when(2, 11, 0), + ItemSpec::new( + "Meet Jo for the weekly project review", + "Review current risks and choose the next visible milestone.", + &["Website Refresh", "Meetings", "Work"], + ) + .priority(1) + .when(3, 15, 0) + .recurring("weekly"), + ItemSpec::new( + "Buy archival boxes for the studio move", + "Measure the largest flat work before choosing box sizes.", + &["Studio Move", "Errands", "Out and About"], + ) + .when(4, 17, 0), + ItemSpec::new( + "Idea: publish a short monthly field note", + "A single observation, one image, and a link to related work.", + &["Ideas", "Work"], + ), + ItemSpec::new( + "Send the accessibility checklist to Jo", + "Checklist sent with the content inventory template.", + &["Website Refresh", "Next Actions", "At Computer"], + ) + .done(), + ], + } +} + +fn recipes() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("Recipe Box") + .category("Recipes") + .columns("item:42:Recipe,categories:35:Course / cuisine,note:55:Ingredients and method,value:10:Serves"), + ViewSpec::new("Favorites") + .category("Favorites") + .columns("item:45:Favorite,categories:35:Tags,note:58:Recipe,value:10:Serves"), + ViewSpec::new("Main Dishes") + .category("Main Dishes") + .columns("item:45:Main dish,categories:35:Tags,note:58:Recipe,value:10:Serves"), + ViewSpec::new("Quick Meals") + .category("Quick") + .columns("item:45:Recipe,categories:35:Tags,note:58:Method,value:10:Serves"), + ViewSpec::new("Vegetarian") + .category("Vegetarian") + .columns("item:45:Recipe,categories:35:Tags,note:58:Recipe,value:10:Serves"), + ViewSpec::new("By Course") + .category("Recipes") + .columns("item:45:Recipe,categories:35:Tags,value:10:Serves") + .sections("Breakfast|category=Breakfast;Main dishes|category='Main Dishes';Desserts|category=Desserts;Drinks|category=Drinks"), + ViewSpec::new("All Items").sort("updated"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — Recipe Box", + description: "A browseable cooking notebook organized by course, cuisine, dietary tags, ingredients, and favorites.", + categories: vec![ + category("Recipes", None, "standard", "recipe:", false), + category("Course", None, "standard", "", false), + category("Breakfast", Some("Course"), "standard", "", true), + category("Main Dishes", Some("Course"), "standard", "", true), + category("Desserts", Some("Course"), "standard", "", true), + category("Drinks", Some("Course"), "standard", "", true), + category("Cuisine", None, "standard", "", false), + category("Mediterranean", Some("Cuisine"), "standard", "", false), + category("East Asian", Some("Cuisine"), "standard", "", false), + category("Northern European", Some("Cuisine"), "standard", "", false), + category("Diet", None, "standard", "", false), + category("Vegetarian", Some("Diet"), "standard", "", false), + category("Vegan", Some("Diet"), "standard", "", false), + category("Gluten Free", Some("Diet"), "standard", "", false), + category("Quick", None, "standard", "quick,15 minute,20 minute", false), + category("Favorites", None, "standard", "favorite", false), + category("Ingredients", None, "standard", "", false), + category("Beans", Some("Ingredients"), "standard", "beans,chickpeas", false), + category("Rice", Some("Ingredients"), "standard", "rice", false), + category("Fruit", Some("Ingredients"), "standard", "apple,pear,lemon", false), + category("Servings", None, "numeric", "", false), + ], + views, + rules: vec![ + rule("Recipes", "text~recipe:", "assign:"), + rule("Quick", "note~minutes", "assign:"), + ], + macros: vec![ + agenda_macro( + "newrecipe", + "{newrecipe}\n{inputtext;Recipe name;%name}\nn{type;Recipe: }{type;%name}{enter}{F5}{type;Ingredients:}{enter;2}{type;Method:}{enter;2}{type;Notes:}{F5}", + ), + agenda_macro( + "cookingnote", + "{cookingnote}\n{inputtext;What did you learn while cooking?;%note}\nn{type;Kitchen note: }{type;%note}{enter}", + ), + ], + items: vec![ + ItemSpec::new( + "Recipe: Lemon chickpea skillet", + "Ingredients: chickpeas, spinach, lemon, garlic, olive oil, cumin.\n\nMethod: Warm garlic and cumin in oil, fold in chickpeas and spinach, then finish with lemon. About 20 minutes.", + &["Recipes", "Main Dishes", "Mediterranean", "Vegetarian", "Vegan", "Gluten Free", "Beans", "Fruit", "Quick", "Favorites", "Servings"], + ) + .value(3.0), + ItemSpec::new( + "Recipe: Ginger mushroom rice bowl", + "Ingredients: cooked rice, mushrooms, ginger, scallions, tamari, sesame oil.\n\nMethod: Brown mushrooms well, add ginger and tamari, and serve over hot rice. About 20 minutes.", + &["Recipes", "Main Dishes", "East Asian", "Vegetarian", "Vegan", "Rice", "Quick", "Servings"], + ) + .value(2.0), + ItemSpec::new( + "Recipe: Apple oat breakfast jars", + "Ingredients: rolled oats, grated apple, yogurt or oat milk, cinnamon, toasted seeds.\n\nMethod: Mix in jars and chill overnight.", + &["Recipes", "Breakfast", "Northern European", "Vegetarian", "Fruit", "Servings"], + ) + .value(4.0), + ItemSpec::new( + "Recipe: Pear and cocoa cups", + "Ingredients: ripe pear, cocoa, thick yogurt, toasted hazelnuts.\n\nMethod: Layer diced pear with cocoa yogurt and finish with nuts.", + &["Recipes", "Desserts", "Vegetarian", "Fruit", "Quick", "Servings"], + ) + .value(2.0), + ItemSpec::new( + "Recipe: Rosemary citrus cooler", + "Ingredients: orange, lemon, rosemary, sparkling water, ice.\n\nMethod: Muddle one rosemary sprig with citrus juice, strain, and top with sparkling water.", + &["Recipes", "Drinks", "Vegan", "Gluten Free", "Fruit", "Quick", "Servings"], + ) + .value(4.0), + ], + } +} + +fn rides() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("Ride Log") + .category("Rides") + .sort("when") + .columns("item:45:Ride,categories:35:Bike / type / weather,when:20:Date,value:14:Distance km:sum,note:35:Notes"), + ViewSpec::new("Training") + .category("Training") + .sort("when") + .columns("item:48:Session,categories:32:Bike / route,when:20:Date,value:14:Distance km:sum"), + ViewSpec::new("Long Rides") + .category("Rides") + .filter("value>=50") + .sort("updated") + .columns("item:48:Ride,categories:35:Bike / route,when:20:Date,value:14:Distance km:avg"), + ViewSpec::new("By Bicycle") + .category("Rides") + .columns("item:45:Ride,when:20:Date,value:14:Distance km:sum,categories:35:Type / weather") + .sections("Road bike|category='Road Bike';Touring bike|category='Touring Bike';City bike|category='City Bike'"), + ViewSpec::new("Maintenance") + .category("Maintenance") + .sort("when") + .hide_done() + .columns("item:52:Maintenance,categories:30:Bicycle,when:20:Due,priority:5:P,note:40:Details"), + ViewSpec::new("Datebook").kind("datebook", "30").sort("when"), + ViewSpec::new("All Items").sort("updated"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — Bicycle Log", + description: "A cycling log for rides, bicycles, routes, conditions, distance totals, training, and maintenance.", + categories: vec![ + category("Rides", None, "standard", "ride:", false), + category("Ride Type", None, "standard", "", false), + category("Commute", Some("Ride Type"), "standard", "commute", false), + category( + "Training", + Some("Ride Type"), + "standard", + "training,interval", + false, + ), + category( + "Leisure", + Some("Ride Type"), + "standard", + "leisure,tour", + false, + ), + category("Bicycles", None, "standard", "", false), + category( + "Road Bike", + Some("Bicycles"), + "standard", + "road bike", + false, + ), + category( + "Touring Bike", + Some("Bicycles"), + "standard", + "touring bike", + false, + ), + category( + "City Bike", + Some("Bicycles"), + "standard", + "city bike", + false, + ), + category("Weather", None, "standard", "", false), + category("Clear", Some("Weather"), "standard", "sunny,clear", false), + category( + "Cloudy", + Some("Weather"), + "standard", + "cloudy,overcast", + false, + ), + category("Rain", Some("Weather"), "standard", "rain,wet", false), + category("Routes", None, "standard", "route:", false), + category( + "River Loop", + Some("Routes"), + "standard", + "river loop", + false, + ), + category( + "North Ridge", + Some("Routes"), + "standard", + "north ridge", + false, + ), + category( + "Maintenance", + None, + "standard", + "service,replace,lubricate,maintenance", + false, + ), + category("Distance", None, "numeric", "", false), + ], + views, + rules: vec![ + rule("Rides", "text~ride:", "assign:"), + rule("Maintenance", "text~service or text~replace", "assign:"), + ], + macros: vec![ + agenda_macro( + "logride", + "{logride}\n{inputtext;Ride summary;%ride}\nn{type;Ride: }{type;%ride}{enter}", + ), + agenda_macro( + "servicereminder", + "{servicereminder}\n{inputtext;What needs service?;%service}\nn{type;Service }{type;%service}{enter}", + ), + ], + items: vec![ + ItemSpec::new( + "Ride: sunrise River Loop training", + "Steady endurance pace. Light headwind on the return leg; average speed 24.1 km/h.", + &[ + "Rides", + "Training", + "Road Bike", + "River Loop", + "Clear", + "Distance", + ], + ) + .when(-5, 6, 30) + .value(42.6) + .done(), + ItemSpec::new( + "Ride: wet commute to the library", + "Low traffic and fresh brake pads worked well in the rain.", + &["Rides", "Commute", "City Bike", "Rain", "Distance"], + ) + .when(-3, 8, 15) + .value(11.8) + .done(), + ItemSpec::new( + "Ride: North Ridge café tour", + "Long rolling route with a steep final climb; carry an extra bottle next time.", + &[ + "Rides", + "Leisure", + "Touring Bike", + "North Ridge", + "Cloudy", + "Distance", + ], + ) + .when(-1, 9, 0) + .value(67.4) + .done(), + ItemSpec::new( + "Ride: short recovery spin on the River Loop", + "Keep the effort conversational and skip the hill repeats.", + &[ + "Rides", + "Training", + "Road Bike", + "River Loop", + "Clear", + "Distance", + ], + ) + .when(2, 17, 30) + .value(24.0), + ItemSpec::new( + "Replace touring bike chain", + "Chain checker is past 0.75; inspect the cassette at the same time.", + &["Maintenance", "Touring Bike"], + ) + .priority(1) + .when(4, 10, 0), + ItemSpec::new( + "Service city bike lights", + "Clean charging contacts and test the rear mount.", + &["Maintenance", "City Bike"], + ) + .priority(2) + .when(7, 18, 0), + ], + } +} + +fn people() -> PresetSpec { + let mut views = vec![ + ViewSpec::new("People Directory") + .category("People Records") + .columns("item:40:Person,categories:36:Team / status,note:58:Profile,when:20:Next review"), + ViewSpec::new("Upcoming Reviews") + .category("Reviews") + .sort("when") + .hide_done() + .columns("item:48:Review,categories:32:Team / person,when:20:When,priority:5:P,note:42:Preparation"), + ViewSpec::new("Goals") + .category("Goals") + .sort("priority") + .hide_done() + .columns("item:52:Goal,categories:34:Person / team,when:20:Target,priority:5:P,note:40:Evidence"), + ViewSpec::new("Development") + .category("Development") + .sort("when") + .hide_done() + .columns("item:50:Development action,categories:34:Person / team,when:20:Target,note:45:Notes"), + ViewSpec::new("Candidates") + .category("Candidate") + .sort("updated") + .hide_done() + .columns("item:42:Candidate,categories:34:Role / stage,note:58:Interview notes,priority:5:P"), + ViewSpec::new("Achievements") + .category("Achievements") + .sort("updated") + .columns("item:50:Achievement,categories:34:Person / team,when:20:Date,note:45:Evidence"), + ViewSpec::new("All Items").sort("updated"), + ]; + views.extend(common_tail()); + PresetSpec { + title: "Rogue Agenda — People Manager", + description: "A lightweight people workspace for profiles, teams, goals, reviews, development, achievements, and candidates.", + categories: vec![ + category("People Records", None, "standard", "person:", false), + category("Teams", None, "standard", "", false), + category("Product", Some("Teams"), "standard", "", false), + category("Operations", Some("Teams"), "standard", "", false), + category("Community", Some("Teams"), "standard", "", false), + category("People Status", None, "standard", "", false), + category("Active", Some("People Status"), "standard", "", true), + category("Candidate", Some("People Status"), "standard", "candidate:", true), + category("Alumni", Some("People Status"), "standard", "", true), + category("Reviews", None, "standard", "review:", false), + category("Goals", None, "standard", "goal:", false), + category("Development", None, "standard", "development:,learn,training", false), + category("Achievements", None, "standard", "achievement:", false), + category("Hiring", None, "standard", "interview,candidate,role:", false), + category("Review Date", None, "date", "", false), + category("Progress", None, "numeric", "", false), + ], + views, + rules: vec![ + rule("People Records", "text~person:", "assign:"), + rule("Reviews", "text~review:", "assign:"), + rule("Goals", "text~goal:", "assign:"), + ], + macros: vec![ + agenda_macro( + "addperson", + "{addperson}\n{inputtext;Person name;%name}\n{inputtext;Role or focus;%role}\nn{type;Person: }{type;%name}{type; — }{type;%role}{enter}", + ), + agenda_macro( + "addgoal", + "{addgoal}\n{inputtext;Describe the goal;%goal}\nn{type;Goal: }{type;%goal}{enter}", + ), + ], + items: vec![ + ItemSpec::new( + "Person: Amina Okafor — Product designer", + "Focus: research synthesis and accessible interaction patterns. Prefers written context before design reviews.", + &["People Records", "Product", "Active"], + ), + ItemSpec::new( + "Person: Theo Martin — Operations coordinator", + "Focus: dependable handoffs and lightweight process documentation.", + &["People Records", "Operations", "Active"], + ), + ItemSpec::new( + "Goal: Amina leads two customer research debriefs", + "Evidence: agendas prepared, insights circulated, and follow-up decisions recorded.", + &["Goals", "Product", "Progress"], + ) + .priority(2) + .when(14, 16, 0) + .value(50.0), + ItemSpec::new( + "Review: quarterly conversation with Theo", + "Prepare examples of strong handoffs and identify one process that can be retired.", + &["Reviews", "Operations"], + ) + .priority(1) + .when(5, 10, 30), + ItemSpec::new( + "Development: Amina shadows an accessibility audit", + "Pair with the audit facilitator and write a one-page reflection afterward.", + &["Development", "Product"], + ) + .when(9, 13, 0), + ItemSpec::new( + "Achievement: Theo simplified the intake checklist", + "The revised checklist removed four duplicate questions and reduced incomplete requests.", + &["Achievements", "Operations"], + ) + .when(-4, 9, 0) + .done(), + ItemSpec::new( + "Candidate: Jules Rivera — community producer", + "Strong facilitation examples. Next interview should explore editorial planning and conflict resolution.", + &["Candidate", "Hiring", "Community"], + ) + .priority(2) + .when(3, 14, 30), + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn every_preset_builds_a_rich_valid_workspace() { + for preset in [ + Preset::Accounts, + Preset::Study, + Preset::Planner, + Preset::Recipes, + Preset::Rides, + Preset::People, + ] { + let directory = tempdir().unwrap(); + let path = directory.path().join(format!("{}.agnd", preset.name())); + let mut database = Database::open(&path).unwrap(); + database.apply_preset(preset).unwrap(); + + let item_count: i64 = database + .conn + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + .unwrap(); + assert!(item_count >= 5, "{} has too few items", preset.name()); + assert!(database.categories().unwrap().len() >= 10); + assert!(database.views().unwrap().len() >= 7); + assert!(database.macros().unwrap().len() >= 2); + assert_eq!( + database + .conn + .query_row( + "SELECT value FROM meta WHERE key='preset.name'", + [], + |row| { row.get::<_, String>(0) } + ) + .unwrap(), + preset.name() + ); + } + } + + #[test] + fn presets_refuse_to_replace_an_existing_document() { + let directory = tempdir().unwrap(); + let path = directory.path().join("existing.agnd"); + Database::open(&path).unwrap(); + let mut reopened = Database::open(&path).unwrap(); + let error = reopened.apply_preset(Preset::Planner).unwrap_err(); + assert!(error.to_string().contains("only initialize a new document")); + } + + #[test] + fn a_preset_cannot_be_applied_twice_in_one_session() { + let directory = tempdir().unwrap(); + let path = directory.path().join("twice.agnd"); + let mut database = Database::open(&path).unwrap(); + database.apply_preset(Preset::Study).unwrap(); + assert!(database.apply_preset(Preset::Study).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 8c664f8..e2ad92c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use crossterm::{ execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use db::Database; +use db::{Database, Preset}; use ratatui::{Terminal, backend::CrosstermBackend}; #[derive(Debug, Parser)] @@ -34,6 +34,9 @@ struct Args { /// Populate a new document with representative items #[arg(long)] demo: bool, + /// Initialize a new document with a complete example workspace + #[arg(long, value_enum, conflicts_with = "demo")] + preset: Option, /// Import a text or iCalendar (.ics) file, then exit #[arg(long)] import: Option, @@ -58,6 +61,9 @@ fn main() -> Result<()> { if args.demo { db.seed_demo()?; } + if let Some(preset) = args.preset { + db.apply_preset(preset)?; + } if let Some(import) = args.import { let count = db.import_path(&import)?; println!("Imported {count} item(s) into {}", path.display());