From 8822acd2b54e381655864c3293134c98cef07daf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:21:29 +0000 Subject: [PATCH 1/4] Taskbook 2.0: subcommands, one id grammar, 1 = high priority, typed errors Breaking changes to the CLI, TUI command line and MCP tools; see docs/upgrading-to-2.0.md (next commit). - CLI: actions are clap subcommands (`tb check 1`). The 1.x flags are rewritten to the matching subcommand by cli::rewrite_legacy_args and print a deprecation warning (removal planned for 3.0). - Ids are always bare numbers; `@` always names a board. `@3` in an id position is rejected with a hint. move/priority/due/tag accept several ids. Shared grammar lives in taskbook_common::id and board::parse_item_input (used by both CLI and TUI). - Priority: new Priority enum, 1 = high, 2 = medium, 3 = normal (default). Stored in `_priority`; the legacy `priority` field (1.x and Node.js scale) is still written for old readers and read only when `_priority` is absent, so existing data needs no migration. - Errors: Taskbook no longer prints. Every mutation validates all ids (existence, task-ness) before changing anything and returns a typed TaskbookError; main prints it once and exits 1. Previously-ignored input is now an error: non-numeric ids, invalid p: tokens, p:/due: on notes, unknown list filters/boards, stray tag tokens, task-only ops on notes. - TUI: slash-command and shortcut errors go to the status bar instead of exiting the TUI. - MCP: priority uses the new scale; edit_item validates all fields and applies them in one write; non-integer ids are rejected. - delete/restore write the archive once per call instead of per item. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014rB6uhLWQEZyDE3XSNsyTZ --- crates/taskbook-client/src/cli.rs | 441 ++++ crates/taskbook-client/src/commands.rs | 407 +++- crates/taskbook-client/src/error.rs | 110 +- crates/taskbook-client/src/main.rs | 345 +-- crates/taskbook-client/src/mcp/mod.rs | 64 +- crates/taskbook-client/src/mcp/tools.rs | 198 +- crates/taskbook-client/src/render.rs | 195 +- crates/taskbook-client/src/taskbook.rs | 1877 ++++++----------- crates/taskbook-client/src/tui/actions.rs | 229 +- crates/taskbook-client/src/tui/app.rs | 58 +- .../taskbook-client/src/tui/autocomplete.rs | 20 +- .../taskbook-client/src/tui/command_parser.rs | 437 ++-- crates/taskbook-client/src/tui/mod.rs | 2 +- .../src/tui/widgets/help_popup.rs | 18 +- .../src/tui/widgets/item_row.rs | 10 +- crates/taskbook-common/src/board.rs | 135 +- crates/taskbook-common/src/due.rs | 8 + crates/taskbook-common/src/encryption.rs | 30 +- crates/taskbook-common/src/id.rs | 136 ++ crates/taskbook-common/src/lib.rs | 3 + crates/taskbook-common/src/models/mod.rs | 7 + crates/taskbook-common/src/models/task.rs | 186 +- crates/taskbook-common/src/priority.rs | 166 ++ 23 files changed, 2607 insertions(+), 2475 deletions(-) create mode 100644 crates/taskbook-client/src/cli.rs create mode 100644 crates/taskbook-common/src/id.rs create mode 100644 crates/taskbook-common/src/priority.rs diff --git a/crates/taskbook-client/src/cli.rs b/crates/taskbook-client/src/cli.rs new file mode 100644 index 0000000..696e0a9 --- /dev/null +++ b/crates/taskbook-client/src/cli.rs @@ -0,0 +1,441 @@ +//! Command-line definition (clap) and the 1.x flag compatibility shim. + +use std::ffi::OsString; +use std::path::PathBuf; + +use clap::{ArgAction, CommandFactory, Parser, Subcommand}; + +use taskbook_common::id::{self, IdError}; + +const AFTER_HELP: &str = "\ +Item ids are bare numbers (3). @name always names a board. +Priorities: p1 = high, p2 = medium, p3 = normal (the default). +Running tb with no command opens the interactive board. + +Examples: + tb task @coding +urgent Review PR #42 p:1 due:tomorrow+0930 + tb note @coding Mergesort worst case is O(n log n) + tb check 1 2 + tb priority 3 4 p1 + tb move 3 @reviews + tb due 3 2026-07-01 + tb due 3 none + tb tag 3 +frontend -urgent + tb edit 3 Merge PR #42 + tb list pending @coding +urgent + tb find documentation"; + +#[derive(Parser, Debug)] +#[command( + name = "tb", + version, + about = "Tasks, boards & notes for the command-line habitat", + after_help = AFTER_HELP, + disable_version_flag = true +)] +pub struct Cli { + /// Use a custom taskbook directory + #[arg(long = "taskbook-dir", value_name = "PATH", global = true)] + pub taskbook_dir: Option, + + /// Print version + #[arg(short = 'v', long, action = ArgAction::Version)] + version: Option, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Show items grouped by board + Board, + + /// Show items grouped by creation date + Timeline, + + /// Show archived (deleted) items + Archive, + + /// Create a task + #[command( + override_usage = "tb task [@BOARD]... [+TAG]... [p:1-3] [due:DATE] ..." + )] + Task { + #[arg( + required = true, + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "INPUT" + )] + input: Vec, + }, + + /// Create a note (opens $EDITOR when no text is given) + #[command(override_usage = "tb note [@BOARD]... [+TAG]... [TEXT]...")] + Note { + #[arg( + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "INPUT" + )] + input: Vec, + }, + + /// Toggle tasks between done and pending + Check { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Toggle tasks between in-progress and pending + Begin { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Toggle the star on items + Star { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Move items to the archive + Delete { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Restore items from the archive (ids as shown by `tb archive`) + Restore { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Copy item descriptions to the clipboard + Copy { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + }, + + /// Replace an item's description + Edit { + #[arg(value_parser = parse_id_arg, value_name = "ID")] + id: u64, + #[arg( + required = true, + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "DESCRIPTION" + )] + description: Vec, + }, + + /// Edit a note in $EDITOR + EditNote { + #[arg(value_parser = parse_id_arg, value_name = "ID")] + id: u64, + }, + + /// Put items on the given boards (replacing their current boards) + #[command(override_usage = "tb move ... <@BOARD>...")] + Move { + #[arg(required = true, value_name = "ARGS")] + args: Vec, + }, + + /// Set task priority (1/high, 2/medium, 3/normal; p1 and p:1 also work) + #[command(override_usage = "tb priority ... ")] + Priority { + #[arg(required = true, value_name = "ARGS")] + args: Vec, + }, + + /// Set or clear task due dates + #[command( + override_usage = "tb due ... ", + after_help = "DATE: YYYY-MM-DD, \"YYYY-MM-DD HH:MM\", today, tomorrow, now, \ + or today/tomorrow/now+HHMM" + )] + Due { + #[arg(required = true, value_name = "ARGS")] + args: Vec, + }, + + /// Add (+tag) or remove (-tag) tags on items + #[command(override_usage = "tb tag ... <+TAG|-TAG>...")] + Tag { + #[arg(required = true, allow_hyphen_values = true, value_name = "ARGS")] + args: Vec, + }, + + /// Search descriptions, note bodies and tags (any term matches) + Find { + #[arg(required = true, allow_hyphen_values = true, value_name = "TERM")] + terms: Vec, + }, + + /// Show items matching every filter + #[command( + after_help = "FILTER: pending, progress, done, task, note, starred, +tag, @board \ + (or a bare board name)" + )] + List { + #[arg(value_name = "FILTER")] + filters: Vec, + }, + + /// Archive all completed tasks + Clear, + + /// Register a new sync server account + Register { + #[arg(long)] + server: Option, + #[arg(long)] + username: Option, + #[arg(long)] + email: Option, + #[arg(long)] + password: Option, + }, + + /// Log in to a sync server account + Login { + #[arg(long)] + server: Option, + #[arg(long)] + username: Option, + #[arg(long)] + password: Option, + /// Encryption key (base64) + #[arg(long)] + key: Option, + }, + + /// Log out and delete stored credentials + Logout, + + /// Show sync status + Status, + + /// Push local data to the sync server + Migrate, + + /// Run as an MCP stdio server (for Claude Code and other agents) + Mcp, +} + +fn parse_id_arg(s: &str) -> Result { + id::parse_id(s).map_err(|e| match e { + IdError::AtPrefixed(t) => format!( + "ids are bare numbers and @ names a board; use `{}`", + &t[1..] + ), + _ => "ids are numbers".to_string(), + }) +} + +/// 1.x action flags and the subcommand each one became. +const LEGACY_FLAGS: &[(&str, Option<&str>, &str)] = &[ + ("--archive", Some("-a"), "archive"), + ("--begin", Some("-b"), "begin"), + ("--check", Some("-c"), "check"), + ("--clear", None, "clear"), + ("--copy", Some("-y"), "copy"), + ("--delete", Some("-d"), "delete"), + ("--due", None, "due"), + ("--edit", Some("-e"), "edit"), + ("--edit-note", None, "edit-note"), + ("--find", Some("-f"), "find"), + ("--list", Some("-l"), "list"), + ("--mcp", None, "mcp"), + ("--move", Some("-m"), "move"), + ("--note", Some("-n"), "note"), + ("--priority", Some("-p"), "priority"), + ("--restore", Some("-r"), "restore"), + ("--star", Some("-s"), "star"), + ("--tag", None, "tag"), + ("--task", Some("-t"), "task"), + ("--timeline", Some("-i"), "timeline"), + ("--register", None, "register"), + ("--login", None, "login"), + ("--logout", None, "logout"), + ("--status", None, "status"), + ("--migrate", None, "migrate"), +]; + +fn legacy_subcommand(arg: &str) -> Option<&'static str> { + LEGACY_FLAGS + .iter() + .find(|(long, short, _)| arg == *long || Some(arg) == *short) + .map(|(_, _, sub)| *sub) +} + +/// Rewrite a 1.x flag-style invocation (`tb --check 1`, `tb -t @work x`, +/// `tb --cli`) into subcommand form (`tb check 1`, `tb task @work x`, +/// `tb board`). +/// +/// Returns the rewritten arguments plus, when a legacy flag was replaced, the +/// `(flag, subcommand)` pair so the caller can print a deprecation notice. +/// Arguments that already name a subcommand are returned unchanged. +pub fn rewrite_legacy_args( + mut args: Vec, +) -> (Vec, Option<(String, &'static str)>) { + // Skip leading global options; drop the 1.x `--cli` switch. + let mut start = 1; + let mut saw_cli = false; + while let Some(arg) = args.get(start).and_then(|a| a.to_str()) { + if arg == "--taskbook-dir" { + start += 2; + } else if arg.starts_with("--taskbook-dir=") { + start += 1; + } else if arg == "--cli" { + args.remove(start); + saw_cli = true; + } else { + break; + } + } + + let first = args.get(start).and_then(|a| a.to_str()).unwrap_or(""); + let is_subcommand = Cli::command() + .get_subcommands() + .any(|sub| sub.get_name() == first); + if !is_subcommand { + let legacy = args[start.min(args.len())..] + .iter() + .take_while(|a| *a != "--") + .position(|a| a.to_str().and_then(legacy_subcommand).is_some()); + if let Some(offset) = legacy { + let flag = args.remove(start + offset); + let flag = flag.to_string_lossy().into_owned(); + let sub = legacy_subcommand(&flag).expect("matched above"); + args.insert(start, sub.into()); + return (args, Some((flag, sub))); + } + } + + if saw_cli { + if args.len() <= start { + args.push("board".into()); + } + return (args, Some(("--cli".to_string(), "board"))); + } + (args, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rewrite(args: &[&str]) -> (Vec, Option) { + let (out, legacy) = rewrite_legacy_args(args.iter().map(OsString::from).collect()); + ( + out.into_iter().map(|a| a.into_string().unwrap()).collect(), + legacy.map(|(flag, _)| flag), + ) + } + + fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(args) + } + + #[test] + fn test_clap_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn test_rewrite_flag_to_subcommand() { + assert_eq!( + rewrite(&["tb", "--check", "1", "2"]), + ( + vec!["tb".into(), "check".into(), "1".into(), "2".into()], + Some("--check".into()) + ) + ); + assert_eq!(rewrite(&["tb", "-t", "x"]).0, ["tb", "task", "x"]); + } + + #[test] + fn test_rewrite_keeps_global_dir_in_front() { + assert_eq!( + rewrite(&["tb", "--taskbook-dir", "/d", "--star", "1"]).0, + ["tb", "--taskbook-dir", "/d", "star", "1"] + ); + } + + #[test] + fn test_rewrite_finds_flag_after_options() { + assert_eq!( + rewrite(&["tb", "--server", "u", "--register"]).0, + ["tb", "register", "--server", "u"] + ); + } + + #[test] + fn test_rewrite_leaves_subcommands_alone() { + assert_eq!( + rewrite(&["tb", "task", "use", "-c", "flag"]), + ( + vec![ + "tb".into(), + "task".into(), + "use".into(), + "-c".into(), + "flag".into() + ], + None + ) + ); + } + + #[test] + fn test_rewrite_cli_flag() { + assert_eq!( + rewrite(&["tb", "--cli"]), + (vec!["tb".into(), "board".into()], Some("--cli".into())) + ); + assert_eq!( + rewrite(&["tb", "--cli", "--list", "done"]).0, + ["tb", "list", "done"] + ); + } + + #[test] + fn test_ids_reject_at_prefix_with_hint() { + let err = parse(&["tb", "check", "@3"]).unwrap_err().to_string(); + assert!(err.contains("use `3`"), "{err}"); + let err = parse(&["tb", "edit", "@3", "x"]).unwrap_err().to_string(); + assert!(err.contains("use `3`"), "{err}"); + } + + #[test] + fn test_ids_reject_garbage() { + assert!(parse(&["tb", "check", "1", "foo"]).is_err()); + assert!(parse(&["tb", "check"]).is_err()); + } + + #[test] + fn test_hyphen_values_reach_commands() { + let cli = parse(&["tb", "tag", "3", "-urgent"]).unwrap(); + assert!( + matches!(cli.command, Some(Command::Tag { ref args }) if args == &["3", "-urgent"]) + ); + let cli = parse(&["tb", "task", "fix", "-v", "flag"]).unwrap(); + assert!( + matches!(cli.command, Some(Command::Task { ref input }) if input == &["fix", "-v", "flag"]) + ); + } + + #[test] + fn test_bare_words_are_not_a_command() { + assert!(parse(&["tb", "buy", "milk"]).is_err()); + } + + #[test] + fn test_no_command_means_tui() { + assert!(parse(&["tb"]).unwrap().command.is_none()); + } +} diff --git a/crates/taskbook-client/src/commands.rs b/crates/taskbook-client/src/commands.rs index 255aca2..9b2e465 100644 --- a/crates/taskbook-client/src/commands.rs +++ b/crates/taskbook-client/src/commands.rs @@ -1,139 +1,338 @@ +use std::collections::HashMap; use std::path::PathBuf; use base64::Engine; use colored::Colorize; use crate::api_client::{ApiClient, EncryptedItemData}; +use crate::auth; +use crate::cli::{Cli, Command}; use crate::config::Config; use crate::credentials::Credentials; use crate::directory::resolve_taskbook_directory; +use crate::editor; use crate::error::{Result, TaskbookError}; +use crate::mcp; +use crate::render::{Render, Stats}; use crate::storage::{LocalStorage, StorageBackend}; -use crate::taskbook::Taskbook; +use crate::taskbook::{Items, NewNote, NewTask, Taskbook}; +use crate::tui; use taskbook_common::encryption::encrypt_item; - -/// Execute CLI commands -#[allow(clippy::too_many_arguments)] -pub fn run( - input: Vec, - archive: bool, - task: bool, - restore: bool, - note: bool, - delete: bool, - check: bool, - begin: bool, - star: bool, - priority: bool, - due: bool, - copy: bool, - timeline: bool, - find: bool, - list: bool, - edit: bool, - edit_note: bool, - r#move: bool, - clear: bool, - tag: bool, - taskbook_dir: Option, -) -> Result<()> { - let taskbook = Taskbook::new(taskbook_dir.as_deref())?; - - if archive { - return taskbook.display_archive(); - } - - if task { - return taskbook.create_task(&input); - } - - if restore { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.restore_items(&ids); - } - - if note { - // If no description provided, open external editor - if input.is_empty() { - return taskbook.create_note_with_editor(); +use taskbook_common::{board, due, id, Priority, StorageItem}; + +/// Execute a parsed command line. +pub fn run(cli: Cli) -> Result<()> { + let dir = cli.taskbook_dir; + let Some(command) = cli.command else { + return tui::run(dir.as_deref()); + }; + + match command { + Command::Register { + server, + username, + email, + password, + } => auth::register( + server.as_deref(), + username.as_deref(), + email.as_deref(), + password.as_deref(), + ), + Command::Login { + server, + username, + password, + key, + } => auth::login( + server.as_deref(), + username.as_deref(), + password.as_deref(), + key.as_deref(), + ), + Command::Logout => auth::logout(), + Command::Status => auth::status(), + Command::Migrate => migrate(dir), + Command::Mcp => mcp::run(dir.as_deref()), + command => { + let config = Config::load_or_default(); + let taskbook = Taskbook::from_config(&config, dir.as_deref())?; + let render = Render::new(config); + run_item_command(&taskbook, &render, command) } - return taskbook.create_note(&input); - } - - if edit_note { - return taskbook.edit_note_in_editor(&input); - } - - if delete { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.delete_items(&ids); - } - - if check { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.check_tasks(&ids); - } - - if begin { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.begin_tasks(&ids); - } - - if star { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.star_items(&ids); - } - - if priority { - return taskbook.update_priority(&input); } +} - if due { - return taskbook.update_due_date(&input); +fn run_item_command(tb: &Taskbook, render: &Render, command: Command) -> Result<()> { + match command { + Command::Board => show_boards(render, &tb.items()?, None), + Command::Timeline => { + let data = tb.items()?; + render.display_by_date(&group_by_date(&data)); + render.display_stats(&Stats::from_items(data.values())); + } + Command::Archive => render.display_by_date(&group_by_date(&tb.archived_items()?)), + Command::Task { input } => { + let parsed = board::parse_cli_input(&input)?; + let id = tb.create_task(NewTask { + boards: parsed.boards, + description: parsed.description, + priority: parsed.priority.unwrap_or_default(), + tags: parsed.tags, + due_date: parsed.due_date, + })?; + render.success_create(id, true); + } + Command::Note { input } if input.is_empty() => match editor::create_note_in_editor()? { + Some(content) => { + let id = tb.create_note(NewNote { + title: content.title, + body: content.body, + ..NewNote::default() + })?; + render.success_create(id, false); + } + None => render.note_cancelled(), + }, + Command::Note { input } => { + let parsed = board::parse_cli_input(&input)?; + if parsed.priority.is_some() { + return Err(TaskbookError::NoteField("priority")); + } + if parsed.due_date.is_some() { + return Err(TaskbookError::NoteField("due date")); + } + let id = tb.create_note(NewNote { + boards: parsed.boards, + title: parsed.description, + body: None, + tags: parsed.tags, + })?; + render.success_create(id, false); + } + Command::Check { ids } => { + let out = tb.toggle_check(&ids)?; + render.mark_complete(&out.on); + render.mark_incomplete(&out.off); + } + Command::Begin { ids } => { + let out = tb.toggle_begin(&ids)?; + render.mark_started(&out.on); + render.mark_paused(&out.off); + } + Command::Star { ids } => { + let out = tb.toggle_star(&ids)?; + render.mark_starred(&out.on); + render.mark_unstarred(&out.off); + } + Command::Delete { ids } => render.success_delete(&tb.delete(&ids)?), + Command::Restore { ids } => render.success_restore(&tb.restore(&ids)?), + Command::Copy { ids } => render.success_copy_to_clipboard(&tb.copy_to_clipboard(&ids)?), + Command::Edit { id, description } => { + tb.edit_description(id, description.join(" "))?; + render.success_edit(id); + } + Command::EditNote { id } => { + let (title, body) = tb.note_content(id)?; + match editor::edit_existing_note_in_editor(&title, body.as_deref())? { + Some(content) => { + tb.set_note_content(id, content.title, content.body)?; + render.success_edit(id); + } + None => render.note_cancelled(), + } + } + Command::Move { args } => { + let (ids, boards) = id::split_leading_ids(&args)?; + let boards = tb.move_to_boards(&ids, boards.to_vec())?; + render.success_move(&ids, &boards); + } + Command::Priority { args } => { + let (level, ids) = match args.split_last() { + Some((level, ids)) if !ids.is_empty() => (level, ids), + _ => return Err(TaskbookError::Usage("tb priority ... ")), + }; + let priority: Priority = level.parse()?; + let ids = id::parse_ids(ids)?; + tb.set_priority(&ids, priority)?; + render.success_priority(&ids, priority); + } + Command::Due { args } => { + let (ids, value) = id::split_leading_ids(&args)?; + let due_date = parse_due_value(&value.join(" "))?; + tb.set_due_date(&ids, due_date)?; + render.success_due_date(&ids, due_date); + } + Command::Tag { args } => { + let (ids, tokens) = id::split_leading_ids(&args)?; + let (mut add, mut remove) = (Vec::new(), Vec::new()); + for token in tokens { + if let Some(tag) = token.strip_prefix('+').filter(|t| !t.is_empty()) { + add.push(tag.to_string()); + } else if let Some(tag) = token.strip_prefix('-').filter(|t| !t.is_empty()) { + remove.push(tag.to_string()); + } else { + return Err(TaskbookError::InvalidTag(token.clone())); + } + } + let (added, removed) = tb.update_tags(&ids, add, remove)?; + render.success_tag(&ids, &added, &removed); + } + Command::Find { terms } => { + let mut data = tb.items()?; + data.retain(|_, item| item_matches_terms(item, &terms)); + show_boards(render, &data, None); + } + Command::List { filters } => { + let mut data = tb.items()?; + let known_boards = Taskbook::boards_of(&data); + let boards = apply_list_filters(&mut data, &filters, &known_boards)?; + show_boards(render, &data, boards); + } + Command::Clear => { + let ids = tb.clear_completed()?; + if ids.is_empty() { + render.nothing_to_clear(); + } else { + render.success_clear(&ids); + } + } + Command::Register { .. } + | Command::Login { .. } + | Command::Logout + | Command::Status + | Command::Migrate + | Command::Mcp => unreachable!("handled in run"), } + Ok(()) +} - if copy { - let ids: Vec = input.iter().filter_map(|s| s.parse().ok()).collect(); - return taskbook.copy_to_clipboard(&ids); - } +/// Render `data` grouped by board (all of its boards, or just `only`), then +/// the overall stats. +fn show_boards(render: &Render, data: &Items, only: Option>) { + let boards = only.unwrap_or_else(|| Taskbook::boards_of(data)); + render.display_by_board(&group_by_board(data, &boards)); + render.display_stats(&Stats::from_items(data.values())); +} - if timeline { - taskbook.display_by_date()?; - return taskbook.display_stats(); +fn parse_due_value(raw: &str) -> Result> { + match raw.trim() { + "" => Err(TaskbookError::MissingDueDate), + none if none.eq_ignore_ascii_case("none") => Ok(None), + value => due::parse_due_date(value) + .map(Some) + .ok_or_else(|| TaskbookError::InvalidDueDate(value.to_string())), } +} - if find { - return taskbook.find_items(&input); - } +/// True when any term matches the item: `+tag` terms match tags; other terms +/// match the description, note body or tags (case-insensitive substring). +fn item_matches_terms(item: &StorageItem, terms: &[String]) -> bool { + terms.iter().any(|term| { + let term = term.to_lowercase(); + let in_tags = |q: &str| item.tags().iter().any(|t| t.to_lowercase().contains(q)); + match term.strip_prefix('+') { + Some(tag) if !tag.is_empty() => in_tags(tag), + _ => { + item.description().to_lowercase().contains(&term) + || item + .note_body() + .is_some_and(|b| b.to_lowercase().contains(&term)) + || in_tags(&term) + } + } + }) +} - if list { - taskbook.list_by_attributes(&input)?; - return taskbook.display_stats(); - } +/// Item predicates for `tb list`. +fn list_attribute(name: &str) -> Option bool> { + let pred: fn(&StorageItem) -> bool = match name { + "star" | "starred" => |i| i.is_starred(), + "done" | "checked" | "complete" => |i| i.as_task().is_some_and(|t| t.is_complete), + "progress" | "started" | "begun" | "in_progress" => { + |i| i.as_task().is_some_and(|t| t.in_progress) + } + "pending" | "unchecked" | "incomplete" => |i| { + i.as_task() + .is_some_and(|t| !t.is_complete && !t.in_progress) + }, + "todo" | "task" | "tasks" => |i| i.is_task(), + "note" | "notes" => |i| !i.is_task(), + _ => return None, + }; + Some(pred) +} - if edit { - return taskbook.edit_description(&input); +/// Apply `tb list` filters to `data` (every filter must match). Returns the +/// boards to display when any were named. +/// +/// A term is, in order: `+tag`; `@board`; a state/type attribute; or a bare +/// name of an existing board. Anything else is an error, as is an `@board` +/// that does not exist. +fn apply_list_filters( + data: &mut Items, + filters: &[String], + known_boards: &[String], +) -> Result>> { + let find_board = |name: &str| { + let normalized = board::normalize_board_name(name); + known_boards + .iter() + .find(|b| board::board_eq(b, &normalized)) + .cloned() + }; + + let mut boards: Vec = Vec::new(); + for term in filters { + if let Some(tag) = term.strip_prefix('+').filter(|t| !t.is_empty()) { + let tag = board::normalize_tag(tag); + data.retain(|_, item| item.tags().iter().any(|t| t.eq_ignore_ascii_case(&tag))); + } else if term.starts_with('@') && term.len() > 1 { + let b = find_board(term).ok_or_else(|| { + TaskbookError::UnknownBoard(board::display_name(&board::normalize_board_name(term))) + })?; + boards.push(b); + } else if let Some(pred) = list_attribute(&term.to_lowercase()) { + data.retain(|_, item| pred(item)); + } else if let Some(b) = find_board(term) { + boards.push(b); + } else { + return Err(TaskbookError::UnknownFilter(term.clone())); + } } - if r#move { - return taskbook.move_boards(&input); + if boards.is_empty() { + return Ok(None); } + boards.dedup_by(|a, b| board::board_eq(a, b)); + Ok(Some(boards)) +} - if clear { - return taskbook.clear(); +fn group_by_board<'a>(data: &'a Items, boards: &[String]) -> HashMap> { + let mut grouped: HashMap> = HashMap::new(); + for item in data.values() { + for b in boards { + if item.boards().iter().any(|ib| board::board_eq(ib, b)) { + grouped.entry(b.clone()).or_default().push(item); + } + } } + grouped +} - if tag { - return taskbook.update_tags(&input); +fn group_by_date(data: &Items) -> HashMap> { + let mut grouped: HashMap> = HashMap::new(); + for item in data.values() { + grouped + .entry(item.date().to_string()) + .or_default() + .push(item); } - - // Default: display board view and stats - taskbook.display_by_board()?; - taskbook.display_stats() + grouped } /// Migrate local data to the remote server. -pub fn migrate(taskbook_dir: Option) -> Result<()> { +fn migrate(taskbook_dir: Option) -> Result<()> { let creds = Credentials::load()?.ok_or_else(|| { TaskbookError::Auth("not logged in — run `tb register` or `tb login` first".to_string()) })?; diff --git a/crates/taskbook-client/src/error.rs b/crates/taskbook-client/src/error.rs index bcff472..cc186fe 100644 --- a/crates/taskbook-client/src/error.rs +++ b/crates/taskbook-client/src/error.rs @@ -1,15 +1,65 @@ use thiserror::Error; +use taskbook_common::board::CliParseError; +use taskbook_common::id::IdError; +use taskbook_common::priority::ParsePriorityError; + +/// Every user-facing failure. `Display` is the complete message: callers +/// print it once and never render a second, separate explanation. #[derive(Error, Debug)] pub enum TaskbookError { - #[error("IO error: {0}")] + #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - #[error("Invalid item ID: {0}")] - InvalidId(u64), + #[error(transparent)] + Id(#[from] IdError), + + #[error("{}", unknown_ids_message(.0))] + UnknownIds(Vec), + + #[error("{}", not_a_task_message(.0))] + NotATask(Vec), + + #[error("item {0} is a task, not a note")] + NotANote(u64), + + #[error("usage: {0}")] + Usage(&'static str), + + #[error("no description given")] + MissingDescription, + + #[error("no board given (name one with @board)")] + MissingBoard, + + #[error("no board named {0}")] + UnknownBoard(String), + + #[error(transparent)] + InvalidPriority(#[from] ParsePriorityError), + + #[error("{}", taskbook_common::due::invalid_due_date_message(.0))] + InvalidDueDate(String), + + #[error("no due date given (use a date, or none to clear it)")] + MissingDueDate, + + #[error("notes cannot have a {0}")] + NoteField(&'static str), + + #[error("no tags given (use +tag to add, -tag to remove)")] + MissingTags, + + #[error("expected +tag or -tag, got '{0}'")] + InvalidTag(String), + + #[error( + "unknown filter '{0}' (use pending, progress, done, task, note, starred, +tag or @board)" + )] + UnknownFilter(String), #[error("Invalid custom directory: {0}")] InvalidDirectory(String), @@ -20,9 +70,6 @@ pub enum TaskbookError { #[error("Clipboard error: {0}")] Clipboard(String), - #[error("No items to copy")] - NoItemsToCopy, - #[error("TUI error: {0}")] Tui(String), @@ -36,4 +83,55 @@ pub enum TaskbookError { General(String), } +impl From for TaskbookError { + fn from(e: CliParseError) -> Self { + match e { + CliParseError::InvalidDueDate(raw) => TaskbookError::InvalidDueDate(raw), + CliParseError::InvalidPriority(e) => TaskbookError::InvalidPriority(e), + } + } +} + +fn join_ids(ids: &[u64]) -> String { + ids.iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} + +fn unknown_ids_message(ids: &[u64]) -> String { + match ids { + [id] => format!("no item with id {id}"), + _ => format!("no items with ids {}", join_ids(ids)), + } +} + +fn not_a_task_message(ids: &[u64]) -> String { + match ids { + [id] => format!("item {id} is a note, not a task"), + _ => format!("items {} are notes, not tasks", join_ids(ids)), + } +} + pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_messages_pluralize() { + assert_eq!( + TaskbookError::UnknownIds(vec![4]).to_string(), + "no item with id 4" + ); + assert_eq!( + TaskbookError::UnknownIds(vec![4, 5]).to_string(), + "no items with ids 4, 5" + ); + assert_eq!( + TaskbookError::NotATask(vec![2, 3]).to_string(), + "items 2, 3 are notes, not tasks" + ); + } +} diff --git a/crates/taskbook-client/src/main.rs b/crates/taskbook-client/src/main.rs index ae9e276..20020c5 100644 --- a/crates/taskbook-client/src/main.rs +++ b/crates/taskbook-client/src/main.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; use std::process; use clap::Parser; +use colored::Colorize; mod api_client; mod auth; +mod cli; mod commands; mod config; mod credentials; @@ -17,341 +18,19 @@ mod storage; mod taskbook; mod tui; -const HELP_TEXT: &str = r#" - Usage - $ tb [ ...] - - Options - none Display board view - --archive, -a Display archived items - --begin, -b Start/pause task - --check, -c Check/uncheck task - --clear Delete all checked items - --copy, -y Copy item description - --delete, -d Delete item - --due Set/clear due date of task - --edit, -e Edit item description - --edit-note Edit note in external editor - --find, -f Search for items - --help, -h Display help message - --list, -l List items by attributes - --mcp Run as MCP stdio server (for Claude Code etc.) - --move, -m Move item between boards - --note, -n Create note (opens editor if no description) - --priority, -p Update priority of task - --restore, -r Restore items from archive - --star, -s Star/unstar item - --tag Add/remove tags on item - --taskbook-dir Define a custom taskbook directory - --task, -t Create task - --timeline, -i Display timeline view - --version, -v Display installed version - - Server commands - --register Register a new server account - --login Log in to an existing account - --logout Log out and delete credentials - --status Show sync status - --migrate Push local data to server - - Examples - $ tb - $ tb --archive - $ tb --begin 2 3 - $ tb --check 1 2 - $ tb --clear - $ tb --copy 1 2 3 - $ tb --delete 4 - $ tb --due @3 2026-07-01 - $ tb --due @3 "2026-07-01 14:30" - $ tb --due @3 none - $ tb --edit @3 Merge PR #42 - $ tb --find documentation - $ tb --list pending coding - $ tb --move @1 cooking - $ tb --note @coding Mergesort worse-case O(nlogn) - $ tb --priority @3 2 - $ tb --restore 4 - $ tb --star 2 - $ tb --task @coding @reviews Review PR #42 - $ tb --task @coding +urgent Improve documentation - $ tb --task Make some buttercream - $ tb --task Pay rent due:2026-07-01 - $ tb --task Call dentist due:tomorrow+0930 - $ tb --tag @3 +urgent +frontend - $ tb --tag @3 -urgent - $ tb --list +urgent - $ tb --timeline - $ tb --register --server http://localhost:8080 --username user --email a@b.com --password secret123 - $ tb --login --server http://localhost:8080 --username user --password secret123 --key - $ tb --logout - $ tb --status - $ tb --migrate -"#; - -#[derive(Parser)] -#[command( - name = "tb", - version = env!("CARGO_PKG_VERSION"), - about = "Tasks, boards & notes for the command-line habitat", - after_help = HELP_TEXT -)] -struct Cli { - /// Input arguments (task description, IDs, search terms, etc.) - #[arg(trailing_var_arg = true)] - input: Vec, - - /// Display archived items - #[arg(short = 'a', long)] - archive: bool, - - /// Start/pause task - #[arg(short = 'b', long)] - begin: bool, - - /// Check/uncheck task - #[arg(short = 'c', long)] - check: bool, - - /// Delete all checked items - #[arg(long)] - clear: bool, - - /// Copy item description to clipboard - #[arg(short = 'y', long)] - copy: bool, - - /// Delete item - #[arg(short = 'd', long)] - delete: bool, - - /// Edit item description - #[arg(short = 'e', long)] - edit: bool, - - /// Edit note in external editor - #[arg(long)] - edit_note: bool, - - /// Search for items - #[arg(short = 'f', long)] - find: bool, - - /// List items by attributes - #[arg(short = 'l', long)] - list: bool, - - /// Move item between boards - #[arg(short = 'm', long)] - r#move: bool, - - /// Create note - #[arg(short = 'n', long)] - note: bool, - - /// Update priority of task - #[arg(short = 'p', long)] - priority: bool, - - /// Set or clear due date of task - #[arg(long)] - due: bool, - - /// Restore items from archive - #[arg(short = 'r', long)] - restore: bool, - - /// Star/unstar item - #[arg(short = 's', long)] - star: bool, - - /// Add or remove tags on an item - #[arg(long)] - tag: bool, - - /// Create task - #[arg(short = 't', long)] - task: bool, - - /// Display timeline view - #[arg(short = 'i', long)] - timeline: bool, - - /// Define a custom taskbook directory - #[arg(long = "taskbook-dir", value_name = "PATH")] - taskbook_dir: Option, - - /// Run in CLI mode (non-interactive) - #[arg(long)] - cli: bool, - - /// Run as MCP stdio server (for Claude Code and other agents) - #[arg(long)] - mcp: bool, - - // --- Server commands --- - /// Register a new server account - #[arg(long)] - register: bool, - - /// Log in to an existing server account - #[arg(long)] - login: bool, - - /// Log out and delete credentials - #[arg(long)] - logout: bool, - - /// Show sync status - #[arg(long)] - status: bool, - - /// Push local data to server - #[arg(long)] - migrate: bool, - - /// Server URL for register/login - #[arg(long)] - server: Option, - - /// Username for register/login - #[arg(long)] - username: Option, - - /// Email for register - #[arg(long)] - email: Option, - - /// Password for register/login - #[arg(long)] - password: Option, - - /// Encryption key (base64) for login - #[arg(long)] - key: Option, -} - fn main() { - let cli = Cli::parse(); - - // Handle server commands first (interactive prompts for missing values) - if cli.register { - if let Err(e) = auth::register( - cli.server.as_deref(), - cli.username.as_deref(), - cli.email.as_deref(), - cli.password.as_deref(), - ) { - eprintln!("Error: {}", e); - process::exit(1); - } - return; - } + let (args, legacy) = cli::rewrite_legacy_args(std::env::args_os().collect()); + let cli = cli::Cli::parse_from(args); - if cli.login { - if let Err(e) = auth::login( - cli.server.as_deref(), - cli.username.as_deref(), - cli.password.as_deref(), - cli.key.as_deref(), - ) { - eprintln!("Error: {}", e); - process::exit(1); - } - return; - } - - if cli.logout { - if let Err(e) = auth::logout() { - eprintln!("Error: {}", e); - process::exit(1); - } - return; - } - - if cli.status { - if let Err(e) = auth::status() { - eprintln!("Error: {}", e); - process::exit(1); - } - return; - } - - if cli.migrate { - if let Err(e) = commands::migrate(cli.taskbook_dir) { - eprintln!("Error: {}", e); - process::exit(1); - } - return; - } - - if cli.mcp { - if let Err(e) = mcp::run(cli.taskbook_dir.as_deref()) { - eprintln!("MCP server error: {}", e); - process::exit(1); - } - return; - } - - // Determine if we should run TUI or CLI mode - let has_action_flags = cli.archive - || cli.task - || cli.note - || cli.check - || cli.begin - || cli.star - || cli.delete - || cli.restore - || cli.edit - || cli.edit_note - || cli.r#move - || cli.priority - || cli.due - || cli.copy - || cli.find - || cli.list - || cli.clear - || cli.timeline - || cli.tag; - - // Run TUI if: no action flags, no CLI flag, and no input - let run_tui = !cli.cli && !has_action_flags && cli.input.is_empty(); - - if run_tui { - // Run interactive TUI - if let Err(e) = tui::run(cli.taskbook_dir.as_deref()) { - eprintln!("TUI error: {}", e); - process::exit(1); - } - } else { - // Run CLI mode - let result = commands::run( - cli.input, - cli.archive, - cli.task, - cli.restore, - cli.note, - cli.delete, - cli.check, - cli.begin, - cli.star, - cli.priority, - cli.due, - cli.copy, - cli.timeline, - cli.find, - cli.list, - cli.edit, - cli.edit_note, - cli.r#move, - cli.clear, - cli.tag, - cli.taskbook_dir, + if let Some((flag, subcommand)) = legacy { + eprintln!( + "{} `tb {flag}` is deprecated and will be removed in 3.0; use `tb {subcommand}`", + "warning:".yellow().bold(), ); + } - if let Err(e) = result { - eprintln!("{}", e); - process::exit(1); - } + if let Err(e) = commands::run(cli) { + eprintln!("\n {} {}\n", "✖".red(), e); + process::exit(1); } } diff --git a/crates/taskbook-client/src/mcp/mod.rs b/crates/taskbook-client/src/mcp/mod.rs index e6f644e..92c2938 100644 --- a/crates/taskbook-client/src/mcp/mod.rs +++ b/crates/taskbook-client/src/mcp/mod.rs @@ -1,7 +1,7 @@ //! MCP (Model Context Protocol) stdio server. //! //! Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout so taskbook can be -//! used as an MCP server from agents like Claude Code (`tb --mcp`). Uses the +//! used as an MCP server from agents like Claude Code (`tb mcp`). Uses the //! configured storage backend — the remote server storage when sync is //! enabled. Only JSON-RPC goes to stdout; diagnostics go to stderr. @@ -373,4 +373,66 @@ mod tests { assert_eq!(lines[1]["id"], 2); assert!(lines[1]["result"]["tools"].is_array()); } + + #[test] + fn edit_item_is_atomic_and_rejects_task_fields_on_notes() { + let tb = test_taskbook(); + request( + &tb, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"create_note","arguments":{"description":"Idea"} + }}), + ); + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ + "name":"edit_item","arguments":{"id":1,"description":"Changed","priority":1} + }}), + ); + assert_eq!(resp["result"]["isError"], true); + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{ + "name":"list_items","arguments":{} + }}), + ); + assert_eq!(tool_payload(&resp)[0]["description"], "Idea"); + } + + #[test] + fn priority_defaults_to_normal_and_rejects_out_of_range() { + let tb = test_taskbook(); + request( + &tb, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"create_task","arguments":{"description":"Plain"} + }}), + ); + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ + "name":"list_items","arguments":{} + }}), + ); + assert_eq!(tool_payload(&resp)[0]["priority"], 3); + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{ + "name":"create_task","arguments":{"description":"Bad","priority":4} + }}), + ); + assert_eq!(resp["result"]["isError"], true); + } + + #[test] + fn delete_items_rejects_non_integer_ids() { + let tb = test_taskbook(); + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"delete_items","arguments":{"ids":[1,"x"]} + }}), + ); + assert_eq!(resp["result"]["isError"], true); + } } diff --git a/crates/taskbook-client/src/mcp/tools.rs b/crates/taskbook-client/src/mcp/tools.rs index f110f9b..c508d83 100644 --- a/crates/taskbook-client/src/mcp/tools.rs +++ b/crates/taskbook-client/src/mcp/tools.rs @@ -2,8 +2,8 @@ use serde_json::{json, Value}; -use crate::taskbook::Taskbook; -use taskbook_common::{board, due, StorageItem}; +use crate::taskbook::{ItemEdit, NewNote, NewTask, TaskState, Taskbook}; +use taskbook_common::{board, due, Priority, StorageItem}; /// JSON Schema definitions for all exposed tools. pub fn definitions() -> Value { @@ -35,7 +35,7 @@ pub fn definitions() -> Value { "properties": { "description": { "type": "string", "description": "Task description" }, "boards": { "type": "array", "items": { "type": "string" }, "description": "Boards to place the task on (defaults to My Board)" }, - "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "1 = normal, 2 = medium, 3 = high (default 1)" }, + "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "1 = high, 2 = medium, 3 = normal (default 3)" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for the task" }, "due_date": { "type": "string", "description": "Due date: YYYY-MM-DD, 'YYYY-MM-DD HH:MM', today, tomorrow, now, or today+HHMM / tomorrow+HHMM / now+HHMM" } }, @@ -75,7 +75,7 @@ pub fn definitions() -> Value { "properties": { "id": { "type": "integer", "description": "Item id" }, "description": { "type": "string", "description": "New description" }, - "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "New priority (tasks only)" }, + "priority": { "type": "integer", "minimum": 1, "maximum": 3, "description": "New priority: 1 = high, 2 = medium, 3 = normal (tasks only)" }, "due_date": { "type": "string", "description": "New due date (YYYY-MM-DD, 'YYYY-MM-DD HH:MM', today, tomorrow, now, today+HHMM / tomorrow+HHMM / now+HHMM) or 'none' to clear (tasks only)" }, "boards": { "type": "array", "items": { "type": "string" }, "description": "Replace the item's boards" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Replace the item's tags" }, @@ -165,7 +165,7 @@ fn item_to_json(item: &StorageItem) -> Value { "pending" }; v["state"] = json!(state); - v["priority"] = json!(task.priority); + v["priority"] = json!(task.priority.level()); if let Some(millis) = task.due_date { v["due_date"] = json!(due::format_due_date(millis)); } @@ -197,35 +197,41 @@ fn opt_string_vec(args: &Value, key: &str) -> Vec { .unwrap_or_default() } +/// A non-empty array of ids. Any non-integer element is an error rather than +/// being skipped. fn arg_ids(args: &Value, key: &str) -> Result, String> { + let invalid = || format!("'{key}' must be a non-empty array of item ids"); let ids: Vec = args .get(key) .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(Value::as_u64).collect()) - .unwrap_or_default(); + .ok_or_else(invalid)? + .iter() + .map(|v| v.as_u64().ok_or_else(invalid)) + .collect::>()?; if ids.is_empty() { - Err(format!("'{key}' must be a non-empty array of item ids")) - } else { - Ok(ids) + return Err(invalid()); } + Ok(ids) } fn parse_due_arg(raw: &str) -> Result, String> { if raw.eq_ignore_ascii_case("none") { return Ok(None); } - due::parse_due_date(raw).map(Some).ok_or_else(|| { - format!("invalid due date '{raw}' (use YYYY-MM-DD, today, tomorrow or none)") - }) + due::parse_due_date(raw) + .map(Some) + .ok_or_else(|| due::invalid_due_date_message(raw)) } -fn parse_priority_arg(args: &Value) -> Result, String> { +fn parse_priority_arg(args: &Value) -> Result, String> { match args.get("priority") { None => Ok(None), - Some(v) => match v.as_u64() { - Some(p @ 1..=3) => Ok(Some(p as u8)), - _ => Err("'priority' must be 1, 2 or 3".to_string()), - }, + Some(v) => v + .as_u64() + .and_then(|p| u8::try_from(p).ok()) + .and_then(Priority::from_level) + .map(Some) + .ok_or_else(|| "'priority' must be 1 (high), 2 (medium) or 3 (normal)".to_string()), } } @@ -235,9 +241,9 @@ fn list_items(taskbook: &Taskbook, args: &Value) -> Result { .and_then(Value::as_bool) .unwrap_or(false); let data = if archived { - taskbook.get_all_archive_items() + taskbook.archived_items() } else { - taskbook.get_all_items() + taskbook.items() } .map_err(|e| e.to_string())?; @@ -314,59 +320,34 @@ fn list_items(taskbook: &Taskbook, args: &Value) -> Result { } fn list_boards(taskbook: &Taskbook) -> Result { - let boards = taskbook.get_all_boards().map_err(|e| e.to_string())?; + let boards = taskbook.boards().map_err(|e| e.to_string())?; Ok(json!(boards)) } fn create_task(taskbook: &Taskbook, args: &Value) -> Result { - let description = arg_str(args, "description")?.trim().to_string(); - if description.is_empty() { - return Err("'description' must not be empty".to_string()); - } - let mut boards: Vec = opt_string_vec(args, "boards") - .iter() - .map(|b| board::normalize_board_name(b)) - .collect(); - if boards.is_empty() { - boards.push(board::DEFAULT_BOARD.to_string()); - } - let priority = parse_priority_arg(args)?.unwrap_or(1); - let tags: Vec = opt_string_vec(args, "tags") - .iter() - .map(|t| board::normalize_tag(t)) - .filter(|t| !t.is_empty()) - .collect(); - let due_date = match args.get("due_date").and_then(Value::as_str) { - Some(raw) => parse_due_arg(raw)?, - None => None, - }; - let id = taskbook - .create_task_direct_with_tags(boards, description, priority, tags, due_date) + .create_task(NewTask { + description: arg_str(args, "description")?.to_string(), + boards: opt_string_vec(args, "boards"), + priority: parse_priority_arg(args)?.unwrap_or_default(), + tags: opt_string_vec(args, "tags"), + due_date: match args.get("due_date").and_then(Value::as_str) { + Some(raw) => parse_due_arg(raw)?, + None => None, + }, + }) .map_err(|e| e.to_string())?; Ok(json!({ "id": id, "type": "task" })) } fn create_note(taskbook: &Taskbook, args: &Value) -> Result { - let description = arg_str(args, "description")?.trim().to_string(); - if description.is_empty() { - return Err("'description' must not be empty".to_string()); - } - let mut boards: Vec = opt_string_vec(args, "boards") - .iter() - .map(|b| board::normalize_board_name(b)) - .collect(); - if boards.is_empty() { - boards.push(board::DEFAULT_BOARD.to_string()); - } - let tags: Vec = opt_string_vec(args, "tags") - .iter() - .map(|t| board::normalize_tag(t)) - .filter(|t| !t.is_empty()) - .collect(); - let id = taskbook - .create_note_direct_with_tags(boards, description, tags) + .create_note(NewNote { + title: arg_str(args, "description")?.to_string(), + boards: opt_string_vec(args, "boards"), + body: None, + tags: opt_string_vec(args, "tags"), + }) .map_err(|e| e.to_string())?; Ok(json!({ "id": id, "type": "note" })) } @@ -374,93 +355,52 @@ fn create_note(taskbook: &Taskbook, args: &Value) -> Result { fn set_task_state(taskbook: &Taskbook, args: &Value) -> Result { let id = arg_u64(args, "id")?; let state = arg_str(args, "state")?; + let parsed: TaskState = state + .parse() + .map_err(|e: crate::error::TaskbookError| e.to_string())?; taskbook - .set_task_state_silent(id, state) + .set_task_state(id, parsed) .map_err(|e| e.to_string())?; Ok(json!({ "id": id, "state": state })) } +/// Validates every field before applying any, and applies them in one save. fn edit_item(taskbook: &Taskbook, args: &Value) -> Result { let id = arg_u64(args, "id")?; - let mut changed: Vec<&str> = Vec::new(); - - if let Some(desc) = args.get("description").and_then(Value::as_str) { - let desc = desc.trim(); - if desc.is_empty() { - return Err("'description' must not be empty".to_string()); - } - taskbook - .edit_description_silent(id, desc) - .map_err(|e| e.to_string())?; - changed.push("description"); - } - if let Some(priority) = parse_priority_arg(args)? { - taskbook - .update_priority_silent(id, priority) - .map_err(|e| e.to_string())?; - changed.push("priority"); - } - if let Some(raw) = args.get("due_date").and_then(Value::as_str) { - let due_date = parse_due_arg(raw)?; - taskbook - .set_due_date_silent(id, due_date) - .map_err(|e| e.to_string())?; - changed.push("due_date"); - } - if args.get("boards").is_some() { - let boards = opt_string_vec(args, "boards"); - if boards.is_empty() { - return Err("'boards' must be a non-empty array of board names".to_string()); - } - taskbook - .move_boards_silent(id, boards) - .map_err(|e| e.to_string())?; - changed.push("boards"); - } - if args.get("tags").is_some() { - let new_tags: Vec = opt_string_vec(args, "tags") - .iter() - .map(|t| board::normalize_tag(t)) - .filter(|t| !t.is_empty()) - .collect(); - let data = taskbook.get_all_items().map_err(|e| e.to_string())?; - let current: Vec = data - .get(&id.to_string()) - .map(|item| item.tags().to_vec()) - .unwrap_or_default(); - taskbook - .update_tags_silent(id, &new_tags, ¤t) - .map_err(|e| e.to_string())?; - changed.push("tags"); - } - if let Some(starred) = args.get("starred").and_then(Value::as_bool) { - taskbook - .set_starred_silent(id, starred) - .map_err(|e| e.to_string())?; - changed.push("starred"); - } - + let edit = ItemEdit { + description: args + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + priority: parse_priority_arg(args)?, + due_date: args + .get("due_date") + .and_then(Value::as_str) + .map(parse_due_arg) + .transpose()?, + boards: args.get("boards").map(|_| opt_string_vec(args, "boards")), + tags: args.get("tags").map(|_| opt_string_vec(args, "tags")), + starred: args.get("starred").and_then(Value::as_bool), + }; + let changed = edit.fields(); if changed.is_empty() { return Err( "no editable fields provided (description, priority, due_date, boards, tags, starred)" .to_string(), ); } + taskbook.edit(id, edit).map_err(|e| e.to_string())?; Ok(json!({ "id": id, "updated": changed })) } fn delete_items(taskbook: &Taskbook, args: &Value) -> Result { let ids = arg_ids(args, "ids")?; - taskbook - .delete_items_silent(&ids) - .map_err(|e| e.to_string())?; - Ok(json!({ "deleted": ids })) + let deleted = taskbook.delete(&ids).map_err(|e| e.to_string())?; + Ok(json!({ "deleted": deleted })) } fn restore_items(taskbook: &Taskbook, args: &Value) -> Result { let ids = arg_ids(args, "ids")?; - taskbook - .restore_items_silent(&ids) - .map_err(|e| e.to_string())?; - Ok(json!({ "restored": ids })) + let restored = taskbook.restore(&ids).map_err(|e| e.to_string())?; + Ok(json!({ "restored": restored })) } diff --git a/crates/taskbook-client/src/render.rs b/crates/taskbook-client/src/render.rs index 0dbfa86..514cf55 100644 --- a/crates/taskbook-client/src/render.rs +++ b/crates/taskbook-client/src/render.rs @@ -5,7 +5,7 @@ use colored::{ColoredString, Colorize}; use crate::config::{Config, Rgb, ThemeColors}; use taskbook_common::board; use taskbook_common::due; -use taskbook_common::StorageItem; +use taskbook_common::{Priority, StorageItem}; /// Statistics about items pub struct Stats { @@ -16,6 +16,29 @@ pub struct Stats { pub notes: usize, } +impl Stats { + pub fn from_items<'a>(items: impl IntoIterator) -> Self { + let (mut complete, mut in_progress, mut pending, mut notes) = (0usize, 0, 0, 0); + for item in items { + match item.as_task() { + Some(t) if t.is_complete => complete += 1, + Some(t) if t.in_progress => in_progress += 1, + Some(_) => pending += 1, + None => notes += 1, + } + } + let total = complete + in_progress + pending; + let percent = (complete * 100).checked_div(total).unwrap_or(0) as u32; + Stats { + percent, + complete, + in_progress, + pending, + notes, + } + } +} + /// Item statistics for a group struct ItemStats { tasks: usize, @@ -171,26 +194,21 @@ impl Render { fn build_message(&self, item: &StorageItem) -> String { if let Some(task) = item.as_task() { let description = &task.description; - let priority = task.priority; - - if !task.is_complete && priority > 1 { - let msg = if priority == 2 { - self.warning(description).underline().to_string() - } else { - self.error(description).underline().to_string() - }; - - let indicator = if priority == 2 { - self.warning("(!)").to_string() - } else { - self.error("(!!)").to_string() - }; - - format!("{} {}", msg, indicator) - } else if task.is_complete { - self.muted(description).strikethrough().to_string() - } else { - description.to_string() + if task.is_complete { + return self.muted(description).strikethrough().to_string(); + } + match task.priority { + Priority::High => format!( + "{} {}", + self.error(description).underline(), + self.error("(!!)") + ), + Priority::Medium => format!( + "{} {}", + self.warning(description).underline(), + self.warning("(!)") + ), + Priority::Normal => description.to_string(), } } else { // Note: add [+] indicator if note has body content @@ -397,49 +415,6 @@ impl Render { println!(" {} {}\n", status, notes_status); } - #[allow(dead_code)] - pub fn invalid_custom_app_dir(&self, path: &str) { - eprintln!( - "\n {} Custom app directory was not found on your system: {}", - self.error("✖"), - self.error(path) - ); - } - - #[allow(dead_code)] - pub fn missing_taskbook_dir_flag_value(&self) { - eprintln!( - "\n {} Please provide a value for --taskbook-dir or remove the flag.", - self.error("✖") - ); - } - - pub fn invalid_id(&self, id: u64) { - eprintln!( - "\n {} Unable to find item with id: {}", - self.error("✖"), - self.muted(&id.to_string()) - ); - } - - pub fn invalid_ids_number(&self) { - eprintln!( - "\n {} More than one ids were given as input", - self.error("✖") - ); - } - - pub fn invalid_priority(&self) { - eprintln!("\n {} Priority can only be 1, 2 or 3", self.error("✖")); - } - - pub fn invalid_due_date(&self) { - eprintln!( - "\n {} Invalid due date. Use YYYY-MM-DD, today, tomorrow or none", - self.error("✖") - ); - } - /// Format IDs as comma-separated string fn format_ids(&self, ids: &[u64]) -> String { ids.iter() @@ -448,6 +423,14 @@ impl Render { .join(", ") } + fn noun(ids: &[u64], singular: &'static str, plural: &'static str) -> &'static str { + if ids.len() > 1 { + plural + } else { + singular + } + } + /// Generic mark message for toggled states fn print_mark_message(&self, ids: &[u64], action: &str, singular: &str, plural: &str) { if ids.is_empty() { @@ -487,18 +470,6 @@ impl Render { self.print_mark_message(ids, "Unstarred", "item", "items"); } - pub fn missing_boards(&self) { - eprintln!("\n {} No boards were given as input", self.error("✖")); - } - - pub fn missing_desc(&self) { - eprintln!("\n {} No description was given as input", self.error("✖")); - } - - pub fn missing_id(&self) { - eprintln!("\n {} No id was given as input", self.error("✖")); - } - pub fn success_create(&self, id: u64, is_task: bool) { let item_type = if is_task { "task:" } else { "note:" }; println!( @@ -521,42 +492,53 @@ impl Render { self.print_mark_message(ids, "Deleted", "item", "items"); } - pub fn success_move(&self, id: u64, boards: &[String]) { - let boards_str = boards.join(", "); + pub fn success_move(&self, ids: &[u64], boards: &[String]) { + let boards_str = boards + .iter() + .map(|b| board::display_name(b)) + .collect::>() + .join(", "); println!( - "\n {} Move item: {} to {}", + "\n {} Moved {} {} to {}", self.success("✔"), - self.muted(&id.to_string()), + Self::noun(ids, "item", "items"), + self.muted(&self.format_ids(ids)), self.muted(&boards_str) ); } - pub fn success_priority(&self, id: u64, level: u8) { - let level_str = match level { - 3 => self.error("high").to_string(), - 2 => self.warning("medium").to_string(), - _ => self.success("normal").to_string(), + pub fn success_priority(&self, ids: &[u64], priority: Priority) { + let level = priority.to_string(); + let level = match priority { + Priority::High => self.error(&level), + Priority::Medium => self.warning(&level), + Priority::Normal => self.success(&level), }; println!( - "\n {} Updated priority of task: {} to {}", + "\n {} Set priority of {} {} to {}", self.success("✔"), - self.muted(&id.to_string()), - level_str + Self::noun(ids, "task", "tasks"), + self.muted(&self.format_ids(ids)), + level ); } - pub fn success_due_date(&self, id: u64, due_date: Option) { + pub fn success_due_date(&self, ids: &[u64], due_date: Option) { + let noun = Self::noun(ids, "task", "tasks"); + let ids = self.muted(&self.format_ids(ids)); match due_date { Some(millis) => println!( - "\n {} Set due date of task: {} to {}", + "\n {} Set due date of {} {} to {}", self.success("✔"), - self.muted(&id.to_string()), + noun, + ids, self.muted(&due::format_due_date(millis)) ), None => println!( - "\n {} Cleared due date of task: {}", + "\n {} Cleared due date of {} {}", self.success("✔"), - self.muted(&id.to_string()) + noun, + ids ), } } @@ -580,29 +562,29 @@ impl Render { ); } - pub fn note_cancelled(&self) { - println!("\n {} Note creation cancelled", self.muted("○")); + pub fn nothing_to_clear(&self) { + println!("\n {} No completed tasks to clear", self.muted("○")); } - pub fn missing_tags(&self) { - eprintln!( - "\n {} No tags were given as input. Use +tag to add or -tag to remove.", - self.error("✖") - ); + pub fn note_cancelled(&self) { + println!("\n {} Note creation cancelled", self.muted("○")); } - pub fn success_tag(&self, id: u64, added: &[String], removed: &[String]) { + pub fn success_tag(&self, ids: &[u64], added: &[String], removed: &[String]) { + let noun = Self::noun(ids, "item", "items"); + let ids = self.muted(&self.format_ids(ids)); if !added.is_empty() { let tags_str = added .iter() - .map(|t| format!("+{}", t)) + .map(|t| board::display_tag(t)) .collect::>() .join(", "); println!( - "\n {} Added tags {} to item: {}", + "\n {} Added tags {} to {} {}", self.success("✔"), self.info(&tags_str), - self.muted(&id.to_string()) + noun, + ids ); } if !removed.is_empty() { @@ -612,10 +594,11 @@ impl Render { .collect::>() .join(", "); println!( - "\n {} Removed tags {} from item: {}", + "\n {} Removed tags {} from {} {}", self.success("✔"), self.warning(&tags_str), - self.muted(&id.to_string()) + noun, + ids ); } } diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 186a5e9..608f089 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -1,1410 +1,729 @@ +//! Storage-agnostic business logic shared by the CLI, TUI and MCP server. +//! +//! Every mutation validates all of its ids (existence, and task-ness where it +//! matters) before changing anything, so an operation either applies to every +//! id or to none. Nothing here prints; callers render outcomes and errors. + use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::str::FromStr; use arboard::Clipboard; use crate::config::Config; use crate::directory::resolve_taskbook_directory; -use crate::editor; use crate::error::{Result, TaskbookError}; -use crate::render::{Render, Stats}; use crate::storage::{LocalStorage, RemoteStorage, StorageBackend}; use taskbook_common::board::{self, DEFAULT_BOARD}; -use taskbook_common::due; -use taskbook_common::{Note, StorageItem, Task}; - -struct CreateOptions { - boards: Vec, - description: String, - id: u64, - priority: u8, - tags: Vec, - due_date: Option, +use taskbook_common::id::IdError; +use taskbook_common::{Note, Priority, StorageItem, Task}; + +pub type Items = HashMap; + +/// Fields for a new task. +#[derive(Debug, Default)] +pub struct NewTask { + /// Board names (normalized on create). Empty means the default board. + pub boards: Vec, + pub description: String, + pub priority: Priority, + pub tags: Vec, + pub due_date: Option, } -pub struct Taskbook { - storage: Box, - render: Render, +/// Fields for a new note. +#[derive(Debug, Default)] +pub struct NewNote { + /// Board names (normalized on create). Empty means the default board. + pub boards: Vec, + pub title: String, + pub body: Option, + pub tags: Vec, } -impl Taskbook { - pub fn new(taskbook_dir: Option<&Path>) -> Result { - let config = Config::load_or_default(); - - let storage: Box = if config.sync.enabled { - Box::new(RemoteStorage::new(&config.sync.server_url)?) - } else { - let resolved_dir = resolve_taskbook_directory(taskbook_dir)?; - Box::new(LocalStorage::new(&resolved_dir)?) - }; +/// Ids partitioned by the state a toggle left them in. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Toggled { + pub on: Vec, + pub off: Vec, +} - let render = Render::new(config); +/// An explicit task state (as opposed to the toggles). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + Pending, + InProgress, + Done, +} - Ok(Self { storage, render }) - } +impl FromStr for TaskState { + type Err = TaskbookError; - /// Construct a Taskbook over an explicit storage backend (for tests). - #[allow(dead_code)] - pub fn with_storage(storage: Box) -> Self { - Self { - storage, - render: Render::new(Config::default()), + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(TaskState::Pending), + "in_progress" => Ok(TaskState::InProgress), + "done" => Ok(TaskState::Done), + other => Err(TaskbookError::General(format!( + "invalid state '{other}' (use pending, in_progress or done)" + ))), } } +} - fn get_data(&self) -> Result> { - self.storage.get() - } - - fn get_archive(&self) -> Result> { - self.storage.get_archive() - } - - fn save(&self, data: &HashMap) -> Result<()> { - self.storage.set(data) - } - - fn save_archive(&self, data: &HashMap) -> Result<()> { - self.storage.set_archive(data) - } - - fn generate_id(&self, data: &HashMap) -> u64 { - let max = data - .keys() - .filter_map(|k| k.parse::().ok()) - .max() - .unwrap_or(0); - max + 1 - } - - fn remove_duplicates(&self, ids: &[u64]) -> Vec { - let mut seen = HashSet::with_capacity(ids.len()); - ids.iter().filter(|id| seen.insert(**id)).copied().collect() - } - - fn get_ids(&self, data: &HashMap) -> HashSet { - data.keys().filter_map(|k| k.parse::().ok()).collect() - } +/// A partial update to one item. `None` leaves a field unchanged. +#[derive(Debug, Default)] +pub struct ItemEdit { + pub description: Option, + pub priority: Option, + /// `Some(None)` clears the due date. + pub due_date: Option>, + pub boards: Option>, + /// Replaces the item's tags. + pub tags: Option>, + pub starred: Option, +} - /// Validate IDs without rendering errors (for TUI/silent methods) - fn validate_ids_silent( - &self, - input_ids: &[u64], - existing_ids: &HashSet, - ) -> Result> { - if input_ids.is_empty() { - return Err(TaskbookError::InvalidId(0)); +impl ItemEdit { + /// Names of the fields this edit changes, in a stable order. + pub fn fields(&self) -> Vec<&'static str> { + let mut fields = Vec::new(); + if self.description.is_some() { + fields.push("description"); } - - let unique_ids = self.remove_duplicates(input_ids); - - for id in &unique_ids { - if !existing_ids.contains(id) { - return Err(TaskbookError::InvalidId(*id)); - } + if self.priority.is_some() { + fields.push("priority"); } - - Ok(unique_ids) - } - - fn validate_ids(&self, input_ids: &[u64], existing_ids: &HashSet) -> Result> { - if input_ids.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); + if self.due_date.is_some() { + fields.push("due_date"); } - - let unique_ids = self.remove_duplicates(input_ids); - - for id in &unique_ids { - if !existing_ids.contains(id) { - self.render.invalid_id(*id); - return Err(TaskbookError::InvalidId(*id)); - } + if self.boards.is_some() { + fields.push("boards"); } - - Ok(unique_ids) - } - - fn get_boards(&self, data: &HashMap) -> Vec { - let mut boards = vec![DEFAULT_BOARD.to_string()]; - - // Iterate items in ID order for deterministic board discovery - let mut items: Vec<_> = data.iter().collect(); - items.sort_by_key(|(k, _)| k.parse::().unwrap_or(u64::MAX)); - - for (_, item) in &items { - for b in item.boards() { - if !boards.iter().any(|existing| board::board_eq(existing, b)) { - boards.push(b.clone()); - } - } + if self.tags.is_some() { + fields.push("tags"); } - - // Sort non-default boards alphabetically (case-insensitive), keeping default first - if boards.len() > 1 { - boards[1..].sort_by_key(|a| a.to_lowercase()); + if self.starred.is_some() { + fields.push("starred"); } - - boards + fields } +} - fn get_options(&self, input: &[String]) -> Result { - if input.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - - let data = self.get_data()?; - let id = self.generate_id(&data); - - let parsed = board::parse_cli_input(input).map_err(|e| match e { - board::CliParseError::InvalidDueDate(raw) => { - self.render.invalid_due_date(); - TaskbookError::General(format!("invalid due date: {raw}")) - } - })?; +pub struct Taskbook { + storage: Box, +} - Ok(CreateOptions { - boards: parsed.boards, - description: parsed.description, - id, - priority: parsed.priority, - tags: parsed.tags, - due_date: parsed.due_date, - }) - } +fn next_id(data: &Items) -> u64 { + data.keys() + .filter_map(|k| k.parse::().ok()) + .max() + .unwrap_or(0) + + 1 +} - fn get_stats(&self, data: &HashMap) -> Stats { - let mut complete = 0; - let mut in_progress = 0; - let mut pending = 0; - let mut notes = 0; - - for item in data.values() { - if let Some(task) = item.as_task() { - if task.is_complete { - complete += 1; - } else if task.in_progress { - in_progress += 1; - } else { - pending += 1; - } - } else { - notes += 1; - } - } +/// Dedupe `ids` (keeping order) and check every one exists in `data`. +fn resolve_ids(data: &Items, ids: &[u64]) -> Result> { + if ids.is_empty() { + return Err(IdError::Missing.into()); + } + let mut seen = HashSet::with_capacity(ids.len()); + let unique: Vec = ids.iter().copied().filter(|id| seen.insert(*id)).collect(); + let unknown: Vec = unique + .iter() + .copied() + .filter(|id| !data.contains_key(&id.to_string())) + .collect(); + if !unknown.is_empty() { + return Err(TaskbookError::UnknownIds(unknown)); + } + Ok(unique) +} - let total = complete + pending + in_progress; - let percent = (complete * 100usize).checked_div(total).unwrap_or(0) as u32; +/// Like [`resolve_ids`], additionally requiring every item to be a task. +fn resolve_task_ids(data: &Items, ids: &[u64]) -> Result> { + let ids = resolve_ids(data, ids)?; + let notes: Vec = ids + .iter() + .copied() + .filter(|id| !data[&id.to_string()].is_task()) + .collect(); + if !notes.is_empty() { + return Err(TaskbookError::NotATask(notes)); + } + Ok(ids) +} - Stats { - percent, - complete, - in_progress, - pending, - notes, +/// Normalize and dedupe board names, falling back to the default board. +fn normalize_boards(boards: Vec) -> Vec { + let mut out: Vec = Vec::new(); + for b in boards { + let b = board::normalize_board_name(&b); + if !out.iter().any(|o| board::board_eq(o, &b)) { + out.push(b); } } + if out.is_empty() { + out.push(DEFAULT_BOARD.to_string()); + } + out +} - fn item_matches_terms(item: &StorageItem, terms: &[String]) -> bool { - for term in terms { - let term_lower = term.to_lowercase(); - - // Check if searching by tag (+tag syntax) - if let Some(tag_query) = term_lower.strip_prefix('+') { - if item - .tags() - .iter() - .any(|t| t.to_lowercase().contains(tag_query)) - { - return true; - } - continue; - } - - // Search description - if item.description().to_lowercase().contains(&term_lower) { - return true; - } - - // Search note body - if let Some(body) = item.note_body() { - if body.to_lowercase().contains(&term_lower) { - return true; - } - } - - // Search tags - if item - .tags() - .iter() - .any(|t| t.to_lowercase().contains(&term_lower)) - { - return true; - } +/// Normalize and dedupe tags, dropping empty ones. +fn normalize_tags(tags: Vec) -> Vec { + let mut out: Vec = Vec::new(); + for t in tags { + let t = board::normalize_tag(&t); + if !t.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&t)) { + out.push(t); } - false } + out +} - fn filter_task(data: &mut HashMap) { - data.retain(|_, item| item.is_task()); +fn non_empty(text: String) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + Err(TaskbookError::MissingDescription) + } else { + Ok(trimmed.to_string()) } +} - fn filter_note(data: &mut HashMap) { - data.retain(|_, item| !item.is_task()); +impl Taskbook { + /// Open the taskbook selected by the config file (remote when sync is + /// enabled, otherwise the resolved local directory). + pub fn new(taskbook_dir: Option<&Path>) -> Result { + Self::from_config(&Config::load_or_default(), taskbook_dir) } - fn filter_starred(data: &mut HashMap) { - data.retain(|_, item| item.is_starred()); + pub fn from_config(config: &Config, taskbook_dir: Option<&Path>) -> Result { + let storage: Box = if config.sync.enabled { + Box::new(RemoteStorage::new(&config.sync.server_url)?) + } else { + let resolved_dir = resolve_taskbook_directory(taskbook_dir)?; + Box::new(LocalStorage::new(&resolved_dir)?) + }; + Ok(Self { storage }) } - fn filter_complete(data: &mut HashMap) { - data.retain(|_, item| item.as_task().map(|t| t.is_complete).unwrap_or(false)); + /// Construct a Taskbook over an explicit storage backend (for tests). + #[allow(dead_code)] + pub fn with_storage(storage: Box) -> Self { + Self { storage } } - fn filter_in_progress(data: &mut HashMap) { - data.retain(|_, item| item.as_task().map(|t| t.in_progress).unwrap_or(false)); + // ---- queries ---- + + pub fn items(&self) -> Result { + self.storage.get() } - fn filter_pending(data: &mut HashMap) { - data.retain(|_, item| { - item.as_task() - .map(|t| !t.is_complete && !t.in_progress) - .unwrap_or(false) - }); + pub fn archived_items(&self) -> Result { + self.storage.get_archive() } - fn filter_by_attributes(&self, attrs: &[String], data: &mut HashMap) { - for attr in attrs { - match attr.as_str() { - "star" | "starred" => Self::filter_starred(data), - "done" | "checked" | "complete" => Self::filter_complete(data), - "progress" | "started" | "begun" => Self::filter_in_progress(data), - "pending" | "unchecked" | "incomplete" => Self::filter_pending(data), - "todo" | "task" | "tasks" => Self::filter_task(data), - "note" | "notes" => Self::filter_note(data), - _ => {} - } - } + pub fn boards(&self) -> Result> { + Ok(Self::boards_of(&self.items()?)) } - fn group_by_board<'a>( - &self, - data: &'a HashMap, - boards: &[String], - ) -> HashMap> { - let mut grouped: HashMap> = HashMap::new(); - - for item in data.values() { - for board in boards { - if item.boards().iter().any(|b| board::board_eq(b, board)) { - grouped.entry(board.clone()).or_default().push(item); + /// The default board followed by every other board on `data`, sorted + /// case-insensitively. + pub fn boards_of(data: &Items) -> Vec { + let mut boards = vec![DEFAULT_BOARD.to_string()]; + let mut items: Vec<_> = data.iter().collect(); + items.sort_by_key(|(k, _)| k.parse::().unwrap_or(u64::MAX)); + for (_, item) in items { + for b in item.boards() { + if !boards.iter().any(|existing| board::board_eq(existing, b)) { + boards.push(b.clone()); } } } - - grouped + boards[1..].sort_by_key(|a| a.to_lowercase()); + boards } - fn group_by_date<'a>( - &self, - data: &'a HashMap, - ) -> HashMap> { - let mut grouped: HashMap> = HashMap::new(); - - for item in data.values() { - let date = item.date().to_string(); - grouped.entry(date).or_default().push(item); - } + // ---- create ---- - grouped + pub fn create_task(&self, new: NewTask) -> Result { + let description = non_empty(new.description)?; + let mut data = self.items()?; + let id = next_id(&data); + let task = Task::new(id, description, normalize_boards(new.boards), new.priority) + .with_tags(normalize_tags(new.tags)) + .with_due_date(new.due_date); + data.insert(id.to_string(), StorageItem::Task(task)); + self.storage.set(&data)?; + Ok(id) } - fn save_item_to_archive(&self, item: StorageItem) -> Result<()> { - let mut archive = self.get_archive()?; - let archive_id = self.generate_id(&archive); - - let mut item = item; - match &mut item { - StorageItem::Task(t) => t.id = archive_id, - StorageItem::Note(n) => n.id = archive_id, - } - - archive.insert(archive_id.to_string(), item); - self.save_archive(&archive) + pub fn create_note(&self, new: NewNote) -> Result { + let title = non_empty(new.title)?; + let mut data = self.items()?; + let id = next_id(&data); + let mut note = Note::new_with_body(id, title, new.body, normalize_boards(new.boards)); + note.tags = normalize_tags(new.tags); + data.insert(id.to_string(), StorageItem::Note(note)); + self.storage.set(&data)?; + Ok(id) } - fn save_item_to_storage(&self, item: StorageItem) -> Result<()> { - let mut data = self.get_data()?; - let restore_id = self.generate_id(&data); + // ---- mutate ---- - let mut item = item; - match &mut item { - StorageItem::Task(t) => t.id = restore_id, - StorageItem::Note(n) => n.id = restore_id, + /// Load, validate `ids`, apply `f` to each item, save. + fn update_items( + &self, + ids: &[u64], + tasks_only: bool, + mut f: impl FnMut(&mut StorageItem), + ) -> Result> { + let mut data = self.items()?; + let ids = if tasks_only { + resolve_task_ids(&data, ids)? + } else { + resolve_ids(&data, ids)? + }; + for id in &ids { + f(data.get_mut(&id.to_string()).expect("resolved")); } - - data.insert(restore_id.to_string(), item); - self.save(&data) - } - - // Public API methods for TUI access - - /// Get all items without rendering (for TUI) - pub fn get_all_items(&self) -> Result> { - self.get_data() + self.storage.set(&data)?; + Ok(ids) } - /// Get all archived items without rendering (for TUI) - pub fn get_all_archive_items(&self) -> Result> { - self.get_archive() + fn update_tasks(&self, ids: &[u64], mut f: impl FnMut(&mut Task)) -> Result> { + self.update_items(ids, true, |item| { + f(item.as_task_mut().expect("resolved as task")) + }) } - /// Get all boards (for TUI) - pub fn get_all_boards(&self) -> Result> { - let data = self.get_data()?; - Ok(self.get_boards(&data)) + /// Toggle tasks between done and pending. Clears in-progress. + pub fn toggle_check(&self, ids: &[u64]) -> Result { + let mut out = Toggled::default(); + self.update_tasks(ids, |task| { + task.in_progress = false; + task.is_complete = !task.is_complete; + let bucket = if task.is_complete { + &mut out.on + } else { + &mut out.off + }; + bucket.push(task.id); + })?; + Ok(out) } - // Silent methods for TUI (no render output) - - /// Create a task with explicit board and description (for TUI) - #[allow(dead_code)] - pub fn create_task_direct( - &self, - boards: Vec, - description: String, - priority: u8, - ) -> Result { - self.create_task_direct_with_tags(boards, description, priority, Vec::new(), None) + /// Toggle tasks between in-progress and pending. Clears done. + pub fn toggle_begin(&self, ids: &[u64]) -> Result { + let mut out = Toggled::default(); + self.update_tasks(ids, |task| { + task.is_complete = false; + task.in_progress = !task.in_progress; + let bucket = if task.in_progress { + &mut out.on + } else { + &mut out.off + }; + bucket.push(task.id); + })?; + Ok(out) } - /// Create a task with tags and optional due date (for TUI/MCP) - pub fn create_task_direct_with_tags( - &self, - boards: Vec, - description: String, - priority: u8, - tags: Vec, - due_date: Option, - ) -> Result { - if description.is_empty() { - return Err(TaskbookError::General("Description cannot be empty".into())); - } - - let mut data = self.get_data()?; - let id = self.generate_id(&data); - let task = - Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); - data.insert(id.to_string(), StorageItem::Task(task)); - self.save(&data)?; - Ok(id) + pub fn toggle_star(&self, ids: &[u64]) -> Result { + let mut out = Toggled::default(); + self.update_items(ids, false, |item| { + let starred = !item.is_starred(); + item.set_starred(starred); + let bucket = if starred { &mut out.on } else { &mut out.off }; + bucket.push(item.id()); + })?; + Ok(out) } - /// Create a note with explicit board and description (for TUI) - #[allow(dead_code)] - pub fn create_note_direct(&self, boards: Vec, description: String) -> Result { - self.create_note_direct_with_tags(boards, description, Vec::new()) + pub fn set_task_state(&self, id: u64, state: TaskState) -> Result<()> { + self.update_tasks(&[id], |task| { + task.is_complete = state == TaskState::Done; + task.in_progress = state == TaskState::InProgress; + })?; + Ok(()) } - /// Create a note with tags (for TUI) - pub fn create_note_direct_with_tags( - &self, - boards: Vec, - description: String, - tags: Vec, - ) -> Result { - if description.is_empty() { - return Err(TaskbookError::General("Description cannot be empty".into())); - } - - let mut data = self.get_data()?; - let id = self.generate_id(&data); - let note = Note::new_with_tags(id, description, boards, tags); - data.insert(id.to_string(), StorageItem::Note(note)); - self.save(&data)?; - Ok(id) + pub fn set_priority(&self, ids: &[u64], priority: Priority) -> Result> { + self.update_tasks(ids, |task| task.priority = priority) } - /// Create a note with title and body (for TUI) - #[allow(dead_code)] - pub fn create_note_with_body_direct( - &self, - boards: Vec, - title: String, - body: Option, - ) -> Result { - if title.is_empty() { - return Err(TaskbookError::InvalidId(0)); - } - - let mut data = self.get_data()?; - let id = self.generate_id(&data); - let note = Note::new_with_body(id, title, body, boards); - data.insert(id.to_string(), StorageItem::Note(note)); - self.save(&data)?; - Ok(id) + pub fn set_due_date(&self, ids: &[u64], due_date: Option) -> Result> { + self.update_tasks(ids, |task| task.due_date = due_date) } - /// Edit note body without CLI output (for TUI) - pub fn edit_note_body_silent(&self, id: u64, body: Option) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - if !item.set_note_body(body) { - return Err(TaskbookError::General("Item is not a note".to_string())); - } + /// Put items on exactly `boards`. Returns the normalized board names. + pub fn move_to_boards(&self, ids: &[u64], boards: Vec) -> Result> { + if boards.is_empty() { + return Err(TaskbookError::MissingBoard); } - - self.save(&data) + let boards = normalize_boards(boards); + self.update_items(ids, false, |item| item.set_boards(boards.clone()))?; + Ok(boards) } - /// Check tasks without CLI output (for TUI) - pub fn check_tasks_silent(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids_silent(ids, &existing_ids)?; - - for id in validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.in_progress = false; - task.is_complete = !task.is_complete; + /// Add and remove tags. Returns the normalized (added, removed) tags. + pub fn update_tags( + &self, + ids: &[u64], + add: Vec, + remove: Vec, + ) -> Result<(Vec, Vec)> { + let add = normalize_tags(add); + let remove = normalize_tags(remove); + if add.is_empty() && remove.is_empty() { + return Err(TaskbookError::MissingTags); + } + self.update_items(ids, false, |item| { + let mut tags: Vec = item.tags().to_vec(); + tags.retain(|t| !remove.iter().any(|r| t.eq_ignore_ascii_case(r))); + for tag in &add { + if !tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { + tags.push(tag.clone()); } } - } - - self.save(&data) + item.set_tags(tags); + })?; + Ok((add, remove)) } - /// Begin tasks without CLI output (for TUI) - pub fn begin_tasks_silent(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids_silent(ids, &existing_ids)?; - - for id in validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.is_complete = false; - task.in_progress = !task.in_progress; - } - } + /// Apply a partial update to one item in a single save. Task-only fields + /// on a note are rejected before anything changes. + pub fn edit(&self, id: u64, edit: ItemEdit) -> Result<()> { + let ItemEdit { + description, + priority, + due_date, + boards, + tags, + starred, + } = edit; + let description = description.map(non_empty).transpose()?; + if boards.as_ref().is_some_and(Vec::is_empty) { + return Err(TaskbookError::MissingBoard); } + let boards = boards.map(normalize_boards); + let tags = tags.map(normalize_tags); - self.save(&data) - } - - /// Star items without CLI output (for TUI) - pub fn star_items_silent(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids_silent(ids, &existing_ids)?; - - for id in validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - let new_starred = !item.is_starred(); - item.set_starred(new_starred); + let tasks_only = priority.is_some() || due_date.is_some(); + self.update_items(&[id], tasks_only, |item| { + if let Some(d) = &description { + item.set_description(d.clone()); } - } - - self.save(&data) - } - - /// Delete items without CLI output (for TUI) - pub fn delete_items_silent(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids_silent(ids, &existing_ids)?; - - for id in validated_ids { - if let Some(item) = data.remove(&id.to_string()) { - self.save_item_to_archive(item)?; + if let Some(b) = &boards { + item.set_boards(b.clone()); } - } - - self.save(&data) - } - - /// Restore items without CLI output (for TUI) - pub fn restore_items_silent(&self, ids: &[u64]) -> Result<()> { - let mut archive = self.get_archive()?; - let archive_ids = self.get_ids(&archive); - let validated_ids = self.validate_ids_silent(ids, &archive_ids)?; - - for id in validated_ids { - if let Some(item) = archive.remove(&id.to_string()) { - self.save_item_to_storage(item)?; + if let Some(t) = &tags { + item.set_tags(t.clone()); } - } - - self.save_archive(&archive) - } - - /// Edit description without CLI output (for TUI) - pub fn edit_description_silent(&self, id: u64, new_desc: &str) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_description(new_desc.to_string()); - } - - self.save(&data) - } - - /// Move to board without CLI output (for TUI) - pub fn move_boards_silent(&self, id: u64, boards: Vec) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - let normalized: Vec = boards - .into_iter() - .map(|b| board::normalize_board_name(&b)) - .collect(); - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_boards(normalized); - } - - self.save(&data) - } - - /// Update priority without CLI output (for TUI) - pub fn update_priority_silent(&self, id: u64, priority: u8) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.priority = priority; + if let Some(s) = starred { + item.set_starred(s); } - } - - self.save(&data) - } - - /// Clear completed without CLI output (for TUI) - pub fn clear_silent(&self) -> Result { - let data = self.get_data()?; - let mut ids_to_delete: Vec = Vec::new(); - - for (id, item) in &data { - if let Some(task) = item.as_task() { - if task.is_complete { - if let Ok(id) = id.parse::() { - ids_to_delete.push(id); - } + if let Some(task) = item.as_task_mut() { + if let Some(p) = priority { + task.priority = p; + } + if let Some(d) = due_date { + task.due_date = d; } } - } - - if ids_to_delete.is_empty() { - return Ok(0); - } - - let count = ids_to_delete.len(); - let mut data = self.get_data()?; - for id in &ids_to_delete { - if let Some(item) = data.remove(&id.to_string()) { - self.save_item_to_archive(item)?; - } - } - self.save(&data)?; - Ok(count) + })?; + Ok(()) } - /// Copy to clipboard without CLI output (for TUI) - pub fn copy_to_clipboard_silent(&self, ids: &[u64]) -> Result<()> { - let data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids_silent(ids, &existing_ids)?; + pub fn edit_description(&self, id: u64, description: String) -> Result<()> { + self.edit( + id, + ItemEdit { + description: Some(description), + ..ItemEdit::default() + }, + ) + } - let descriptions: Vec = validated_ids - .iter() - .filter_map(|id| data.get(&id.to_string())) - .map(|item| item.description().to_string()) + /// The (title, body) of a note, for opening it in an editor. + pub fn note_content(&self, id: u64) -> Result<(String, Option)> { + let data = self.items()?; + resolve_ids(&data, &[id])?; + let note = data[&id.to_string()] + .as_note() + .ok_or(TaskbookError::NotANote(id))?; + Ok((note.title().to_string(), note.body().map(str::to_string))) + } + + pub fn set_note_content(&self, id: u64, title: String, body: Option) -> Result<()> { + let title = non_empty(title)?; + let mut data = self.items()?; + resolve_ids(&data, &[id])?; + let item = data.get_mut(&id.to_string()).expect("resolved"); + if !item.set_note_body(body) { + return Err(TaskbookError::NotANote(id)); + } + item.set_description(title); + self.storage.set(&data) + } + + /// Move items to the archive. Returns the (deduped) ids that were deleted. + pub fn delete(&self, ids: &[u64]) -> Result> { + let mut data = self.items()?; + let ids = resolve_ids(&data, ids)?; + let mut archive = self.archived_items()?; + for id in &ids { + let mut item = data.remove(&id.to_string()).expect("resolved"); + let archive_id = next_id(&archive); + item.set_id(archive_id); + archive.insert(archive_id.to_string(), item); + } + // Archive first: a failure between the two writes duplicates rather + // than loses items. + self.storage.set_archive(&archive)?; + self.storage.set(&data)?; + Ok(ids) + } + + /// Move archived items back. Returns the (deduped) archive ids restored. + pub fn restore(&self, ids: &[u64]) -> Result> { + let mut archive = self.archived_items()?; + let ids = resolve_ids(&archive, ids)?; + let mut data = self.items()?; + for id in &ids { + let mut item = archive.remove(&id.to_string()).expect("resolved"); + let new_id = next_id(&data); + item.set_id(new_id); + data.insert(new_id.to_string(), item); + } + self.storage.set(&data)?; + self.storage.set_archive(&archive)?; + Ok(ids) + } + + /// Archive every completed task. Returns their ids (ascending). + pub fn clear_completed(&self) -> Result> { + let data = self.items()?; + let mut done: Vec = data + .values() + .filter(|item| item.as_task().is_some_and(|t| t.is_complete)) + .map(StorageItem::id) .collect(); - - if descriptions.is_empty() { - return Err(TaskbookError::NoItemsToCopy); + if done.is_empty() { + return Ok(done); } - - let mut clipboard = - Clipboard::new().map_err(|e| TaskbookError::Clipboard(e.to_string()))?; - clipboard - .set_text(descriptions.join("\n")) - .map_err(|e| TaskbookError::Clipboard(e.to_string()))?; - - Ok(()) + done.sort_unstable(); + self.delete(&done) } - /// Rename a board across all items (for TUI) - pub fn rename_board_silent(&self, old_name: &str, new_name: &str) -> Result { - let mut data = self.get_data()?; + /// Rename a board across all items. Returns the number of items changed. + pub fn rename_board(&self, old_name: &str, new_name: &str) -> Result { + let mut data = self.items()?; + let old_name = board::normalize_board_name(old_name); + let new_name = board::normalize_board_name(new_name); let mut count = 0; - let normalized_new = board::normalize_board_name(new_name); - for item in data.values_mut() { - let boards = item.boards().to_vec(); - if boards.iter().any(|b| board::board_eq(b, old_name)) { - let new_boards: Vec = boards + if item.boards().iter().any(|b| board::board_eq(b, &old_name)) { + let boards = item + .boards() .iter() .map(|b| { - if board::board_eq(b, old_name) { - normalized_new.clone() + if board::board_eq(b, &old_name) { + new_name.clone() } else { b.clone() } }) .collect(); - item.set_boards(new_boards); + item.set_boards(boards); count += 1; } } - if count > 0 { - self.save(&data)?; + self.storage.set(&data)?; } - Ok(count) } - // Public API methods - - pub fn create_note(&self, desc: &[String]) -> Result<()> { - let CreateOptions { - boards, - description, - id, - tags, - .. - } = self.get_options(desc)?; - - if description.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - - let note = Note::new_with_tags(id, description, boards, tags); - let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Note(note)); - self.save(&data)?; - self.render.success_create(id, false); - Ok(()) - } - - /// Create a note using external editor - pub fn create_note_with_editor(&self) -> Result<()> { - let content = editor::create_note_in_editor()?; - - match content { - Some(note_content) => { - let mut data = self.get_data()?; - let id = self.generate_id(&data); - let note = Note::new_with_body( - id, - note_content.title, - note_content.body, - vec![DEFAULT_BOARD.to_string()], - ); - data.insert(id.to_string(), StorageItem::Note(note)); - self.save(&data)?; - self.render.success_create(id, false); - Ok(()) - } - None => { - self.render.note_cancelled(); - Ok(()) - } - } - } - - /// Edit an existing note in external editor - pub fn edit_note_in_editor(&self, input: &[String]) -> Result<()> { - // Parse the ID from input (format: @) - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id_str = target.trim_start_matches('@'); - let id: u64 = id_str.parse().map_err(|_| TaskbookError::InvalidId(0))?; - - let data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(&[id], &existing_ids)?; - let id = validated_ids[0]; - - // Get the current note - let item = data - .get(&id.to_string()) - .ok_or(TaskbookError::InvalidId(id))?; - - let note = item - .as_note() - .ok_or_else(|| TaskbookError::General("Item is not a note".to_string()))?; - - // Open editor with current content - let content = editor::edit_existing_note_in_editor(note.title(), note.body())?; - - match content { - Some(note_content) => { - let mut data = self.get_data()?; - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_description(note_content.title); - item.set_note_body(note_content.body); - } - self.save(&data)?; - self.render.success_edit(id); - Ok(()) - } - None => { - self.render.note_cancelled(); - Ok(()) - } - } - } - - pub fn create_task(&self, desc: &[String]) -> Result<()> { - let CreateOptions { - boards, - description, - id, - priority, - tags, - due_date, - } = self.get_options(desc)?; - - if description.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - - let task = - Task::new_with_tags(id, description, boards, priority, tags).with_due_date(due_date); - let mut data = self.get_data()?; - data.insert(id.to_string(), StorageItem::Task(task)); - self.save(&data)?; - self.render.success_create(id, true); - Ok(()) - } - - pub fn copy_to_clipboard(&self, ids: &[u64]) -> Result<()> { - let data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(ids, &existing_ids)?; - - let mut descriptions = Vec::new(); - for id in &validated_ids { - if let Some(item) = data.get(&id.to_string()) { - descriptions.push(item.description().to_string()); - } - } - - if descriptions.is_empty() { - return Err(TaskbookError::NoItemsToCopy); - } - - let mut clipboard = - Clipboard::new().map_err(|e| TaskbookError::Clipboard(e.to_string()))?; - clipboard - .set_text(descriptions.join("\n")) - .map_err(|e| TaskbookError::Clipboard(e.to_string()))?; - - self.render.success_copy_to_clipboard(&validated_ids); - Ok(()) - } - - pub fn check_tasks(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(ids, &existing_ids)?; - - let mut checked = Vec::new(); - let mut unchecked = Vec::new(); - - for id in &validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.in_progress = false; - task.is_complete = !task.is_complete; - if task.is_complete { - checked.push(*id); - } else { - unchecked.push(*id); - } - } - } - } - - self.save(&data)?; - self.render.mark_complete(&checked); - self.render.mark_incomplete(&unchecked); - Ok(()) - } - - pub fn begin_tasks(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(ids, &existing_ids)?; - - let mut started = Vec::new(); - let mut paused = Vec::new(); - - for id in &validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.is_complete = false; - task.in_progress = !task.in_progress; - if task.in_progress { - started.push(*id); - } else { - paused.push(*id); - } - } - } - } - - self.save(&data)?; - self.render.mark_started(&started); - self.render.mark_paused(&paused); - Ok(()) - } - - pub fn delete_items(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(ids, &existing_ids)?; - - for id in &validated_ids { - if let Some(item) = data.remove(&id.to_string()) { - self.save_item_to_archive(item)?; - } - } - - self.save(&data)?; - self.render.success_delete(&validated_ids); - Ok(()) - } - - pub fn display_archive(&self) -> Result<()> { - let archive = self.get_archive()?; - let grouped = self.group_by_date(&archive); - self.render.display_by_date(&grouped); - Ok(()) - } - - pub fn display_by_board(&self) -> Result<()> { - let data = self.get_data()?; - let boards = self.get_boards(&data); - let grouped = self.group_by_board(&data, &boards); - self.render.display_by_board(&grouped); - Ok(()) - } - - pub fn display_by_date(&self) -> Result<()> { - let data = self.get_data()?; - let grouped = self.group_by_date(&data); - self.render.display_by_date(&grouped); - Ok(()) - } - - pub fn display_stats(&self) -> Result<()> { - let data = self.get_data()?; - let stats = self.get_stats(&data); - self.render.display_stats(&stats); - Ok(()) - } - - pub fn edit_description(&self, input: &[String]) -> Result<()> { - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id_str = target.trim_start_matches('@'); - let id: u64 = id_str.parse().map_err(|_| TaskbookError::InvalidId(0))?; - - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(&[id], &existing_ids)?; - let id = validated_ids[0]; - - let new_desc: String = input + /// Copy item descriptions (newline-separated) to the system clipboard. + pub fn copy_to_clipboard(&self, ids: &[u64]) -> Result> { + let data = self.items()?; + let ids = resolve_ids(&data, ids)?; + let text = ids .iter() - .filter(|x| *x != target) - .cloned() + .map(|id| data[&id.to_string()].description()) .collect::>() - .join(" "); - - if new_desc.is_empty() { - self.render.missing_desc(); - return Err(TaskbookError::InvalidId(0)); - } - - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_description(new_desc); - } - - self.save(&data)?; - self.render.success_edit(id); - Ok(()) - } - - pub fn find_items(&self, terms: &[String]) -> Result<()> { - let data = self.get_data()?; - let mut result: HashMap = HashMap::new(); - - for (id, item) in &data { - if Self::item_matches_terms(item, terms) { - result.insert(id.clone(), item.clone()); - } - } - - let boards = self.get_boards(&result); - let grouped = self.group_by_board(&result, &boards); - self.render.display_by_board(&grouped); - Ok(()) - } - - pub fn list_by_attributes(&self, terms: &[String]) -> Result<()> { - let data = self.get_data()?; - let stored_boards = self.get_boards(&data); - - let mut boards: Vec = Vec::new(); - let mut attributes: Vec = Vec::new(); - let mut tag_filters: Vec = Vec::new(); - - for term in terms { - if term.starts_with('+') && term.len() > 1 { - tag_filters.push(board::normalize_tag(term)); - } else { - let normalized = board::normalize_board_name(term); - if stored_boards - .iter() - .any(|b| board::board_eq(b, &normalized)) - { - if !boards.iter().any(|b| board::board_eq(b, &normalized)) { - boards.push(normalized); - } - } else { - attributes.push(term.clone()); - } - } - } - - let mut filtered_data = data.clone(); - self.filter_by_attributes(&attributes, &mut filtered_data); - - // Filter by tags - if !tag_filters.is_empty() { - filtered_data.retain(|_, item| { - tag_filters.iter().all(|filter_tag| { - item.tags() - .iter() - .any(|t| t.eq_ignore_ascii_case(filter_tag)) - }) - }); - } - - let display_boards = if boards.is_empty() { - self.get_boards(&filtered_data) - } else { - boards - }; - - let grouped = self.group_by_board(&filtered_data, &display_boards); - self.render.display_by_board(&grouped); - Ok(()) - } - - pub fn move_boards(&self, input: &[String]) -> Result<()> { - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id_str = target.trim_start_matches('@'); - let id: u64 = id_str.parse().map_err(|_| TaskbookError::InvalidId(0))?; - - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(&[id], &existing_ids)?; - let id = validated_ids[0]; - - let mut boards: Vec = Vec::new(); - for word in input { - if word != target { - let normalized = board::normalize_board_name(word); - if !boards.iter().any(|b| board::board_eq(b, &normalized)) { - boards.push(normalized); - } - } - } - - if boards.is_empty() { - self.render.missing_boards(); - return Err(TaskbookError::InvalidId(0)); - } - - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_boards(boards.clone()); - } - - self.save(&data)?; - let display_boards: Vec = boards.iter().map(|b| board::display_name(b)).collect(); - self.render.success_move(id, &display_boards); - Ok(()) - } - - pub fn restore_items(&self, ids: &[u64]) -> Result<()> { - let mut archive = self.get_archive()?; - let archive_ids = self.get_ids(&archive); - let validated_ids = self.validate_ids(ids, &archive_ids)?; - - for id in &validated_ids { - if let Some(item) = archive.remove(&id.to_string()) { - self.save_item_to_storage(item)?; - } - } - - self.save_archive(&archive)?; - self.render.success_restore(&validated_ids); - Ok(()) - } - - pub fn star_items(&self, ids: &[u64]) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(ids, &existing_ids)?; - - let mut starred = Vec::new(); - let mut unstarred = Vec::new(); - - for id in &validated_ids { - if let Some(item) = data.get_mut(&id.to_string()) { - let new_starred = !item.is_starred(); - item.set_starred(new_starred); - if new_starred { - starred.push(*id); - } else { - unstarred.push(*id); - } - } - } - - self.save(&data)?; - self.render.mark_starred(&starred); - self.render.mark_unstarred(&unstarred); - Ok(()) - } - - pub fn update_priority(&self, input: &[String]) -> Result<()> { - let level = input - .iter() - .find(|x| matches!(x.as_str(), "1" | "2" | "3")) - .map(|s| s.parse::().unwrap()); - - let level = match level { - Some(l) => l, - None => { - self.render.invalid_priority(); - return Err(TaskbookError::InvalidId(0)); - } - }; - - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id_str = target.trim_start_matches('@'); - let id: u64 = id_str.parse().map_err(|_| TaskbookError::InvalidId(0))?; - - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(&[id], &existing_ids)?; - let id = validated_ids[0]; - - if let Some(item) = data.get_mut(&id.to_string()) { - if let Some(task) = item.as_task_mut() { - task.priority = level; - } - } - - self.save(&data)?; - self.render.success_priority(id, level); - Ok(()) - } - - /// Set or clear a task due date from CLI input. - /// Format: `@ ` - pub fn update_due_date(&self, input: &[String]) -> Result<()> { - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id: u64 = target - .trim_start_matches('@') - .parse() - .map_err(|_| TaskbookError::InvalidId(0))?; - - let value = input.iter().find(|x| *x != target); - let due_date = match value.map(|s| s.as_str()) { - Some("none") => None, - Some(raw) => match due::parse_due_date(raw) { - Some(millis) => Some(millis), - None => { - self.render.invalid_due_date(); - return Err(TaskbookError::General(format!("invalid due date: {raw}"))); - } - }, - None => { - self.render.invalid_due_date(); - return Err(TaskbookError::General("missing due date".to_string())); - } - }; - - self.set_due_date_silent(id, due_date)?; - self.render.success_due_date(id, due_date); - Ok(()) - } - - /// Set a task's state deterministically without CLI output (for MCP) - pub fn set_task_state_silent(&self, id: u64, state: &str) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - let task = item - .as_task_mut() - .ok_or_else(|| TaskbookError::General(format!("item {id} is not a task")))?; - match state { - "done" => { - task.is_complete = true; - task.in_progress = false; - } - "in_progress" => { - task.is_complete = false; - task.in_progress = true; - } - "pending" => { - task.is_complete = false; - task.in_progress = false; - } - other => { - return Err(TaskbookError::General(format!( - "invalid state '{other}' (use pending, in_progress or done)" - ))) - } - } - } - - self.save(&data) - } - - /// Set starred flag explicitly without CLI output (for MCP) - pub fn set_starred_silent(&self, id: u64, starred: bool) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - item.set_starred(starred); - } - - self.save(&data) - } - - /// Set or clear a task due date without CLI output (for TUI/MCP) - pub fn set_due_date_silent(&self, id: u64, due_date: Option) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - let task = item - .as_task_mut() - .ok_or_else(|| TaskbookError::General(format!("item {id} is not a task")))?; - task.due_date = due_date; - } - - self.save(&data) + .join("\n"); + Clipboard::new() + .and_then(|mut c| c.set_text(text)) + .map_err(|e| TaskbookError::Clipboard(e.to_string()))?; + Ok(ids) } +} - pub fn clear(&self) -> Result<()> { - let data = self.get_data()?; - let mut ids_to_delete: Vec = Vec::new(); - - for (id, item) in &data { - if let Some(task) = item.as_task() { - if task.is_complete { - if let Ok(id) = id.parse::() { - ids_to_delete.push(id); - } - } - } - } - - if ids_to_delete.is_empty() { - return Ok(()); - } +#[cfg(test)] +mod tests { + use super::*; - // Delete items without the success message (we'll use success_clear instead) - let mut data = self.get_data()?; - for id in &ids_to_delete { - if let Some(item) = data.remove(&id.to_string()) { - self.save_item_to_archive(item)?; - } - } - self.save(&data)?; - self.render.success_clear(&ids_to_delete); - Ok(()) + fn taskbook() -> Taskbook { + let dir = std::env::temp_dir().join(format!("tb-core-test-{}", uuid::Uuid::new_v4())); + Taskbook::with_storage(Box::new(LocalStorage::new(&dir).unwrap())) } - /// Update tags on an item from CLI input. - /// Format: `@ +tag1 +tag2 -tag3` - /// `+tag` adds a tag, `-tag` removes a tag. - pub fn update_tags(&self, input: &[String]) -> Result<()> { - let targets: Vec<&String> = input.iter().filter(|x| x.starts_with('@')).collect(); - - if targets.is_empty() { - self.render.missing_id(); - return Err(TaskbookError::InvalidId(0)); - } - - if targets.len() > 1 { - self.render.invalid_ids_number(); - return Err(TaskbookError::InvalidId(0)); - } - - let target = targets[0]; - let id_str = target.trim_start_matches('@'); - let id: u64 = id_str.parse().map_err(|_| TaskbookError::InvalidId(0))?; - - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - let validated_ids = self.validate_ids(&[id], &existing_ids)?; - let id = validated_ids[0]; - - let mut add_tags: Vec = Vec::new(); - let mut remove_tags: Vec = Vec::new(); - - for word in input { - if word == target { - continue; - } - if let Some(tag) = word.strip_prefix('+') { - let normalized = board::normalize_tag(&format!("+{}", tag)); - if !normalized.is_empty() { - add_tags.push(normalized); - } - } else if let Some(tag) = word.strip_prefix('-') { - let normalized = tag.trim().to_lowercase(); - if !normalized.is_empty() { - remove_tags.push(normalized); - } - } - } - - if add_tags.is_empty() && remove_tags.is_empty() { - self.render.missing_tags(); - return Err(TaskbookError::General("No tags provided".to_string())); - } - - if let Some(item) = data.get_mut(&id.to_string()) { - let mut current_tags: Vec = item.tags().to_vec(); - - // Remove tags - current_tags.retain(|t| !remove_tags.iter().any(|r| t.eq_ignore_ascii_case(r))); - - // Add tags (dedup) - for tag in &add_tags { - if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { - current_tags.push(tag.clone()); - } - } - - item.set_tags(current_tags); - } - - self.save(&data)?; - self.render.success_tag(id, &add_tags, &remove_tags); - Ok(()) + fn task(tb: &Taskbook, desc: &str) -> u64 { + tb.create_task(NewTask { + description: desc.into(), + ..NewTask::default() + }) + .unwrap() } - /// Update tags without CLI output (for TUI) - pub fn update_tags_silent( - &self, - id: u64, - add_tags: &[String], - remove_tags: &[String], - ) -> Result<()> { - let mut data = self.get_data()?; - let existing_ids = self.get_ids(&data); - self.validate_ids_silent(&[id], &existing_ids)?; - - if let Some(item) = data.get_mut(&id.to_string()) { - let mut current_tags: Vec = item.tags().to_vec(); - - current_tags.retain(|t| !remove_tags.iter().any(|r| t.eq_ignore_ascii_case(r))); - - for tag in add_tags { - if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { - current_tags.push(tag.clone()); - } - } - - item.set_tags(current_tags); - } - - self.save(&data) + fn note(tb: &Taskbook, title: &str) -> u64 { + tb.create_note(NewNote { + title: title.into(), + ..NewNote::default() + }) + .unwrap() + } + + fn get(tb: &Taskbook, id: u64) -> StorageItem { + tb.items().unwrap()[&id.to_string()].clone() + } + + #[test] + fn test_create_rejects_blank_description() { + let tb = taskbook(); + let err = tb + .create_task(NewTask { + description: " ".into(), + ..NewTask::default() + }) + .unwrap_err(); + assert!(matches!(err, TaskbookError::MissingDescription)); + } + + #[test] + fn test_create_defaults_board_and_priority() { + let tb = taskbook(); + let id = task(&tb, "a"); + let item = get(&tb, id); + assert_eq!(item.boards(), [DEFAULT_BOARD]); + assert_eq!(item.as_task().unwrap().priority, Priority::Normal); + } + + #[test] + fn test_unknown_ids_reports_all_and_changes_nothing() { + let tb = taskbook(); + let id = task(&tb, "a"); + let err = tb.toggle_check(&[id, 7, 9]).unwrap_err(); + assert!(matches!(err, TaskbookError::UnknownIds(ref ids) if ids == &[7, 9])); + assert!(!get(&tb, id).as_task().unwrap().is_complete); + } + + #[test] + fn test_empty_ids_is_missing_id() { + let tb = taskbook(); + assert!(matches!( + tb.toggle_star(&[]).unwrap_err(), + TaskbookError::Id(IdError::Missing) + )); + } + + #[test] + fn test_task_only_ops_reject_notes_atomically() { + let tb = taskbook(); + let t = task(&tb, "a"); + let n = note(&tb, "n"); + let err = tb.set_priority(&[t, n], Priority::High).unwrap_err(); + assert!(matches!(err, TaskbookError::NotATask(ref ids) if ids == &[n])); + assert_eq!(get(&tb, t).as_task().unwrap().priority, Priority::Normal); + assert!(matches!( + tb.toggle_check(&[n]).unwrap_err(), + TaskbookError::NotATask(_) + )); + } + + #[test] + fn test_toggle_reports_both_directions() { + let tb = taskbook(); + let a = task(&tb, "a"); + let b = task(&tb, "b"); + tb.toggle_check(&[a]).unwrap(); + let out = tb.toggle_check(&[a, b, a]).unwrap(); + assert_eq!( + out, + Toggled { + on: vec![b], + off: vec![a] + } + ); + } + + #[test] + fn test_multi_id_setters() { + let tb = taskbook(); + let a = task(&tb, "a"); + let b = task(&tb, "b"); + tb.set_priority(&[a, b], Priority::High).unwrap(); + let boards = tb.move_to_boards(&[a, b], vec!["@Work".into()]).unwrap(); + assert_eq!(boards, ["Work"]); + tb.update_tags(&[a, b], vec!["+x".into()], vec![]).unwrap(); + for id in [a, b] { + let item = get(&tb, id); + assert_eq!(item.as_task().unwrap().priority, Priority::High); + assert_eq!(item.boards(), ["Work"]); + assert_eq!(item.tags(), ["x"]); + } + } + + #[test] + fn test_edit_rejects_task_fields_on_note_without_partial_update() { + let tb = taskbook(); + let n = note(&tb, "n"); + let err = tb + .edit( + n, + ItemEdit { + description: Some("changed".into()), + priority: Some(Priority::High), + ..ItemEdit::default() + }, + ) + .unwrap_err(); + assert!(matches!(err, TaskbookError::NotATask(_))); + assert_eq!(get(&tb, n).description(), "n"); + } + + #[test] + fn test_delete_restore_round_trip_and_clear() { + let tb = taskbook(); + let a = task(&tb, "a"); + let b = task(&tb, "b"); + assert_eq!(tb.delete(&[a, a]).unwrap(), vec![a]); + assert_eq!(tb.archived_items().unwrap().len(), 1); + tb.restore(&[1]).unwrap(); + assert!(tb.archived_items().unwrap().is_empty()); + assert_eq!(tb.items().unwrap().len(), 2); + + tb.toggle_check(&[b]).unwrap(); + assert_eq!(tb.clear_completed().unwrap(), vec![b]); + assert!(tb.clear_completed().unwrap().is_empty()); + } + + #[test] + fn test_note_content_requires_note() { + let tb = taskbook(); + let t = task(&tb, "a"); + assert!(matches!( + tb.note_content(t).unwrap_err(), + TaskbookError::NotANote(_) + )); + let n = note(&tb, "title"); + tb.set_note_content(n, "new".into(), Some("body".into())) + .unwrap(); + assert_eq!( + tb.note_content(n).unwrap(), + ("new".to_string(), Some("body".to_string())) + ); } } diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 574aba9..162fb5b 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -2,7 +2,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::editor; use crate::error::Result; -use taskbook_common::board; +use crate::taskbook::{NewNote, NewTask}; +use taskbook_common::{board, Priority}; use super::app::{App, PendingAction, PopupState, StatusKind, ViewMode}; use super::autocomplete; @@ -48,10 +49,12 @@ fn handle_confirm_key(app: &mut App, key: KeyEvent) -> Result<()> { if let Some(action) = app.command_line.pending_confirm.take() { match action { PendingAction::Delete { ids } => { - delete_items(app, &ids)?; + let result = delete_items(app, &ids); + report(app, result); } PendingAction::Clear => { - clear_completed(app)?; + let result = clear_completed(app); + report(app, result); } } } @@ -177,69 +180,79 @@ fn accept_suggestion(app: &mut App) { } } -/// Parse and execute the command line input +/// Parse and execute the command line input. Parse and operation errors +/// (unknown id, note given a priority, ...) go to the status bar instead of +/// ending the TUI. fn execute_input(app: &mut App, input: &str) -> Result<()> { - match command_parser::parse_command(input) { - Ok(cmd) => execute_command(app, cmd), - Err(e) => { - app.set_status(e.message, StatusKind::Error); - Ok(()) - } + let result = command_parser::parse_command(input) + .map_err(|e| e.message) + .and_then(|cmd| execute_command(app, cmd).map_err(|e| e.to_string())); + if let Err(message) = result { + app.set_status(message, StatusKind::Error); + } + Ok(()) +} + +/// Explicit boards, else the filtered board, else the default board. +fn target_boards(app: &App, boards: Vec) -> Vec { + if !boards.is_empty() { + return boards; } + vec![app + .filter + .board_filter + .clone() + .unwrap_or_else(|| board::DEFAULT_BOARD.to_string())] +} + +fn describe_boards(boards: &[String]) -> String { + boards + .iter() + .map(|b| board::display_name(&board::normalize_board_name(b))) + .collect::>() + .join(", ") } /// Execute a parsed command fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { match cmd { ParsedCommand::Task { - board, + boards, description, priority, tags, due_date, } => { - let board_name = board - .map(|b| board::normalize_board_name(&b)) - .or_else(|| app.filter.board_filter.clone()) - .unwrap_or_else(|| "my board".to_string()); - app.taskbook.create_task_direct_with_tags( - vec![board_name.clone()], + let boards = target_boards(app, boards); + let display = describe_boards(&boards); + app.taskbook.create_task(NewTask { + boards, description, priority, tags, due_date, - )?; + })?; app.refresh_items()?; - let display = board::display_name(&board_name); app.set_status(format!("Task created in {}", display), StatusKind::Success); } ParsedCommand::Note { - board, + boards, description, tags, } => { - let board_name = board - .map(|b| board::normalize_board_name(&b)) - .or_else(|| app.filter.board_filter.clone()) - .unwrap_or_else(|| "my board".to_string()); - app.taskbook.create_note_direct_with_tags( - vec![board_name.clone()], - description, + let boards = target_boards(app, boards); + let display = describe_boards(&boards); + let id = app.taskbook.create_note(NewNote { + boards, + title: description, + body: None, tags, - )?; + })?; app.refresh_items()?; - let display = board::display_name(&board_name); app.set_status(format!("Note created in {}", display), StatusKind::Success); // Open editor for the newly created note - // Find the highest ID note (just created) - if let Some(max_id) = app.items.values().map(|i| i.id()).max() { - if let Some(item) = app.items.get(&max_id.to_string()) { - if !item.is_task() { - edit_note_external(app, max_id)?; - } - } - } + edit_note_external(app, id)?; } ParsedCommand::Edit { id, description } => { edit_description(app, id, &description)?; @@ -263,21 +276,9 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { ParsedCommand::Priority { id, level } => { set_priority(app, id, level)?; } - ParsedCommand::Check { ids } => { - for id in &ids { - toggle_check(app, *id)?; - } - } - ParsedCommand::Star { ids } => { - for id in &ids { - toggle_star(app, *id)?; - } - } - ParsedCommand::Begin { ids } => { - for id in &ids { - toggle_begin(app, *id)?; - } - } + ParsedCommand::Check { ids } => toggle_check(app, &ids)?, + ParsedCommand::Star { ids } => toggle_star(app, &ids)?, + ParsedCommand::Begin { ids } => toggle_begin(app, &ids)?, ParsedCommand::Tag { id, add, remove } => { update_tags(app, id, &add, &remove)?; } @@ -380,7 +381,9 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Enter => { if let Some(item) = app.selected_item() { if !item.is_task() { - edit_note_external(app, item.id())?; + let id = item.id(); + let result = edit_note_external(app, id); + report(app, result); } else if app.view == ViewMode::Board && app.filter.board_filter.is_none() { if let Some(board_name) = app.get_board_for_selected() { let display = board::display_name(&board_name); @@ -441,19 +444,19 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { if let Some(item) = app.selected_item() { let id = item.id(); let desc = item.description().to_string(); - app.activate_command_line(&format!("/edit @{} {}", id, desc)); + app.activate_command_line(&format!("/edit {} {}", id, desc)); } } KeyCode::Char('m') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { - app.activate_command_line(&format!("/move @{} @", id)); + app.activate_command_line(&format!("/move {} @", id)); autocomplete::update_suggestions(app); } } KeyCode::Char('p') if app.view != ViewMode::Archive => { if let Some(item) = app.selected_item() { if item.is_task() { - app.activate_command_line(&format!("/priority @{} ", item.id())); + app.activate_command_line(&format!("/priority {} ", item.id())); } } } @@ -469,27 +472,32 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Direct action shortcuts (no command line needed) KeyCode::Char('c') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { - toggle_check(app, id)?; + let result = toggle_check(app, &[id]); + report(app, result); } } KeyCode::Char('b') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { - toggle_begin(app, id)?; + let result = toggle_begin(app, &[id]); + report(app, result); } } KeyCode::Char('s') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { - toggle_star(app, id)?; + let result = toggle_star(app, &[id]); + report(app, result); } } KeyCode::Char('r') if app.view == ViewMode::Archive => { if let Some(id) = app.selected_id() { - restore_item(app, id)?; + let result = restore_item(app, id); + report(app, result); } } KeyCode::Char('y') => { if let Some(id) = app.selected_id() { - copy_to_clipboard(app, id)?; + let result = copy_to_clipboard(app, id); + report(app, result); } } @@ -520,87 +528,110 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Action implementations -fn toggle_check(app: &mut App, id: u64) -> Result<()> { - if let Some(item) = app.items.get(&id.to_string()) { - if item.is_task() { - app.taskbook.check_tasks_silent(&[id])?; - app.refresh_items()?; - app.set_status(format!("Toggled task {}", id), StatusKind::Success); - } +/// Show an operation error in the status bar instead of ending the TUI. +fn report(app: &mut App, result: Result<()>) { + if let Err(e) = result { + app.set_status(e.to_string(), StatusKind::Error); } +} + +fn join_ids(ids: &[u64]) -> String { + ids.iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} + +fn toggle_check(app: &mut App, ids: &[u64]) -> Result<()> { + let out = app.taskbook.toggle_check(ids)?; + app.refresh_items()?; + let mut parts = Vec::new(); + if !out.on.is_empty() { + parts.push(format!("checked {}", join_ids(&out.on))); + } + if !out.off.is_empty() { + parts.push(format!("unchecked {}", join_ids(&out.off))); + } + app.set_status(format!("Tasks {}", parts.join(", ")), StatusKind::Success); Ok(()) } -fn toggle_begin(app: &mut App, id: u64) -> Result<()> { - if let Some(item) = app.items.get(&id.to_string()) { - if item.is_task() { - app.taskbook.begin_tasks_silent(&[id])?; - app.refresh_items()?; - app.set_status( - format!("Toggled in-progress for task {}", id), - StatusKind::Success, - ); - } +fn toggle_begin(app: &mut App, ids: &[u64]) -> Result<()> { + let out = app.taskbook.toggle_begin(ids)?; + app.refresh_items()?; + let mut parts = Vec::new(); + if !out.on.is_empty() { + parts.push(format!("started {}", join_ids(&out.on))); } + if !out.off.is_empty() { + parts.push(format!("paused {}", join_ids(&out.off))); + } + app.set_status(format!("Tasks {}", parts.join(", ")), StatusKind::Success); Ok(()) } -fn toggle_star(app: &mut App, id: u64) -> Result<()> { - app.taskbook.star_items_silent(&[id])?; +fn toggle_star(app: &mut App, ids: &[u64]) -> Result<()> { + let out = app.taskbook.toggle_star(ids)?; app.refresh_items()?; - app.set_status(format!("Toggled star for item {}", id), StatusKind::Success); + let mut parts = Vec::new(); + if !out.on.is_empty() { + parts.push(format!("starred {}", join_ids(&out.on))); + } + if !out.off.is_empty() { + parts.push(format!("unstarred {}", join_ids(&out.off))); + } + app.set_status(format!("Items {}", parts.join(", ")), StatusKind::Success); Ok(()) } fn edit_description(app: &mut App, id: u64, new_desc: &str) -> Result<()> { - app.taskbook.edit_description_silent(id, new_desc)?; + app.taskbook.edit_description(id, new_desc.to_string())?; app.refresh_items()?; app.set_status(format!("Updated item {}", id), StatusKind::Success); Ok(()) } fn move_to_board(app: &mut App, id: u64, board: &str) -> Result<()> { - let board_name = board::normalize_board_name(board); - app.taskbook - .move_boards_silent(id, vec![board_name.clone()])?; + let boards = app + .taskbook + .move_to_boards(&[id], vec![board.to_string()])?; app.refresh_items()?; - let display = board::display_name(&board_name); app.set_status( - format!("Moved item {} to {}", id, display), + format!("Moved item {} to {}", id, describe_boards(&boards)), StatusKind::Success, ); Ok(()) } -fn set_priority(app: &mut App, id: u64, priority: u8) -> Result<()> { - app.taskbook.update_priority_silent(id, priority)?; +fn set_priority(app: &mut App, id: u64, priority: Priority) -> Result<()> { + app.taskbook.set_priority(&[id], priority)?; app.refresh_items()?; app.set_status( - format!("Set priority {} for task {}", priority, id), + format!("Set priority of task {} to {}", id, priority), StatusKind::Success, ); Ok(()) } fn delete_items(app: &mut App, ids: &[u64]) -> Result<()> { - app.taskbook.delete_items_silent(ids)?; + let deleted = app.taskbook.delete(ids)?; app.refresh_items()?; app.set_status( - format!("Deleted {} item(s)", ids.len()), + format!("Deleted {} item(s)", deleted.len()), StatusKind::Success, ); Ok(()) } fn restore_item(app: &mut App, id: u64) -> Result<()> { - app.taskbook.restore_items_silent(&[id])?; + app.taskbook.restore(&[id])?; app.set_view(ViewMode::Archive)?; app.set_status(format!("Restored item {}", id), StatusKind::Success); Ok(()) } fn copy_to_clipboard(app: &mut App, id: u64) -> Result<()> { - app.taskbook.copy_to_clipboard_silent(&[id])?; + app.taskbook.copy_to_clipboard(&[id])?; app.set_status( format!("Copied item {} to clipboard", id), StatusKind::Success, @@ -609,7 +640,8 @@ fn copy_to_clipboard(app: &mut App, id: u64) -> Result<()> { } fn update_tags(app: &mut App, id: u64, add: &[String], remove: &[String]) -> Result<()> { - app.taskbook.update_tags_silent(id, add, remove)?; + app.taskbook + .update_tags(&[id], add.to_vec(), remove.to_vec())?; app.refresh_items()?; let mut parts = Vec::new(); if !add.is_empty() { @@ -636,7 +668,7 @@ fn update_tags(app: &mut App, id: u64, add: &[String], remove: &[String]) -> Res } fn clear_completed(app: &mut App) -> Result<()> { - let count = app.taskbook.clear_silent()?; + let count = app.taskbook.clear_completed()?.len(); app.refresh_items()?; app.set_status( format!("Cleared {} completed task(s)", count), @@ -647,7 +679,7 @@ fn clear_completed(app: &mut App) -> Result<()> { fn rename_board(app: &mut App, old_name: &str, new_name: &str) -> Result<()> { let new_board = board::normalize_board_name(new_name); - let count = app.taskbook.rename_board_silent(old_name, &new_board)?; + let count = app.taskbook.rename_board(old_name, &new_board)?; if let Some(ref filter) = app.filter.board_filter { if board::board_eq(filter, old_name) { @@ -696,8 +728,7 @@ fn edit_note_external(app: &mut App, id: u64) -> Result<()> { match content? { Some(note_content) => { app.taskbook - .edit_description_silent(id, ¬e_content.title)?; - app.taskbook.edit_note_body_silent(id, note_content.body)?; + .set_note_content(id, note_content.title, note_content.body)?; app.refresh_items()?; app.set_status(format!("Updated note {}", id), StatusKind::Success); } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 27ffa2c..67d0814 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -18,10 +18,10 @@ pub fn sort_items_by(items: &mut [&StorageItem], method: SortMethod) { items.sort_by_key(|item| item.id()); } SortMethod::Priority => { - items.sort_by(|a, b| { - let pa = a.as_task().map(|t| t.priority).unwrap_or(0); - let pb = b.as_task().map(|t| t.priority).unwrap_or(0); - pb.cmp(&pa).then_with(|| a.id().cmp(&b.id())) + // High first; notes after all tasks. + items.sort_by_key(|item| { + let priority = item.as_task().map(|t| t.priority); + (priority.is_none(), priority, item.id()) }); } SortMethod::Status => { @@ -180,8 +180,8 @@ pub enum StatusKind { impl App { pub fn new(taskbook_dir: Option<&Path>) -> Result { - let taskbook = Taskbook::new(taskbook_dir)?; let config = Config::load_or_default(); + let taskbook = Taskbook::from_config(&config, taskbook_dir)?; let theme = TuiTheme::from(&config.theme.resolve()); let initial_view = config.default_view; @@ -209,20 +209,14 @@ impl App { command_history: Vec::new(), history_index: None, history_saved_input: String::new(), - cached_stats: Stats { - percent: 0, - complete: 0, - in_progress: 0, - pending: 0, - notes: 0, - }, + cached_stats: Stats::from_items([]), }; app.refresh_items()?; // If restoring archive view, load archive items instead if initial_view == ViewMode::Archive { - app.items = app.taskbook.get_all_archive_items()?; + app.items = app.taskbook.archived_items()?; app.update_display_order(); app.recalculate_stats(); } @@ -232,8 +226,8 @@ impl App { /// Refresh items from storage pub fn refresh_items(&mut self) -> Result<()> { - self.items = self.taskbook.get_all_items()?; - self.boards = self.taskbook.get_all_boards()?; + self.items = self.taskbook.items()?; + self.boards = self.taskbook.boards()?; self.update_display_order(); self.recalculate_stats(); @@ -247,35 +241,7 @@ impl App { /// Recalculate cached statistics fn recalculate_stats(&mut self) { - let mut complete = 0; - let mut in_progress = 0; - let mut pending = 0; - let mut notes = 0; - - for item in self.items.values() { - if let Some(task) = item.as_task() { - if task.is_complete { - complete += 1; - } else if task.in_progress { - in_progress += 1; - } else { - pending += 1; - } - } else { - notes += 1; - } - } - - let total = complete + pending + in_progress; - let percent = (complete * 100usize).checked_div(total).unwrap_or(0) as u32; - - self.cached_stats = Stats { - percent, - complete, - in_progress, - pending, - notes, - }; + self.cached_stats = Stats::from_items(self.items.values()); } /// Check if an item should be shown based on current filters @@ -521,9 +487,9 @@ impl App { // Reload data for archive view if view == ViewMode::Archive { - self.items = self.taskbook.get_all_archive_items()?; + self.items = self.taskbook.archived_items()?; } else { - self.items = self.taskbook.get_all_items()?; + self.items = self.taskbook.items()?; } self.update_display_order(); diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index a4bc50d..385c455 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -26,7 +26,7 @@ const COMMANDS: &[(&str, &str)] = &[ ("quit", "Quit application"), ]; -/// Commands that accept item ID references (@) +/// Commands that accept item ids const ITEM_COMMANDS: &[&str] = &[ "check", "star", "begin", "delete", "edit", "move", "priority", "tag", ]; @@ -65,11 +65,7 @@ pub fn update_suggestions(app: &mut App) { let last_token: String = chars[last_space + 1..cursor].iter().collect(); if let Some(after_at) = last_token.strip_prefix('@') { - // If it's @, user is typing an ID directly — no suggestions - if !after_at.is_empty() && after_at.chars().all(|c| c.is_ascii_digit()) { - return; - } - // Otherwise it's a board reference + // @ always names a board suggest_boards(app, after_at); } else if ITEM_COMMANDS.contains(&command) { // Check if we should suggest items for this argument position @@ -89,13 +85,13 @@ fn should_suggest_items(command: &str, text_to_cursor: &str, _last_space: usize) let args: Vec<&str> = after_command.split_whitespace().collect(); match command { - // /edit @ — only suggest for the first argument + // /edit — only suggest for the first argument "edit" => args.len() <= 1, - // /move @ @ — only suggest for the first argument + // /move @ — only suggest for the first argument "move" => args.len() <= 1, - // /priority @ <1-3> — only suggest for the first argument + // /priority <1-3> — only suggest for the first argument "priority" => args.len() <= 1, - // /tag @ +tag1 -tag2 — only suggest for the first argument + // /tag +tag1 -tag2 — only suggest for the first argument "tag" => args.len() <= 1, // Multi-ID commands: check, star, begin, delete — always suggest _ => true, @@ -209,10 +205,10 @@ fn suggest_items(app: &mut App, partial: &str) { desc.to_string() }; - // Build completion: replace the partial token with @ + // Build completion: replace the partial token with the id let before_token: String = input_chars[..last_space + 1].iter().collect(); let after_cursor: String = input_chars[cursor..].iter().collect(); - let completion = format!("{}@{} {}", before_token, id, after_cursor); + let completion = format!("{}{} {}", before_token, id, after_cursor); app.command_line.suggestions.push(Suggestion { display, diff --git a/crates/taskbook-client/src/tui/command_parser.rs b/crates/taskbook-client/src/tui/command_parser.rs index 3e52e4b..b32c5b7 100644 --- a/crates/taskbook-client/src/tui/command_parser.rs +++ b/crates/taskbook-client/src/tui/command_parser.rs @@ -1,17 +1,21 @@ -use taskbook_common::due; +use taskbook_common::board::{self, ParsedInput}; +use taskbook_common::id::{self, IdError}; +use taskbook_common::Priority; /// Parsed command from the command line input #[derive(Debug, Clone)] pub enum ParsedCommand { Task { - board: Option, + /// Empty means "the filtered board, else the default board". + boards: Vec, description: String, - priority: u8, + priority: Priority, tags: Vec, due_date: Option, }, Note { - board: Option, + /// Empty means "the filtered board, else the default board". + boards: Vec, description: String, tags: Vec, }, @@ -31,7 +35,7 @@ pub enum ParsedCommand { }, Priority { id: u64, - level: u8, + level: Priority, }, Check { ids: Vec, @@ -67,6 +71,20 @@ pub struct ParseError { pub message: String, } +impl ParseError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl From for ParseError { + fn from(e: IdError) -> Self { + Self::new(e.to_string()) + } +} + impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) @@ -123,201 +141,114 @@ pub fn parse_command(input: &str) -> Result { } } -fn parse_task(args: &str) -> Result { - let args = args.trim(); - if args.is_empty() { - return Err(ParseError { - message: "Usage: /task [@board] description [p:1-3] [due:YYYY-MM-DD[THH:MM]]" - .to_string(), - }); - } - - let (board, rest) = if args.starts_with('@') { - match extract_at_board(args) { - Some((name, remaining)) => (Some(name), remaining.to_string()), - None => (None, args.to_string()), - } - } else { - (None, args.to_string()) +/// Parse `[@"quoted board"] ` with the shared item grammar +/// (`@board`, `+tag`, `p:N`, `due:DATE`, description words). +fn parse_item_args(args: &str) -> Result { + let (quoted, rest) = match extract_at_board(args) { + Some((name, rest)) if args.trim_start().starts_with("@\"") => (Some(name), rest), + _ => (None, args), }; - - let mut priority = 1u8; - let mut desc_parts = Vec::new(); - let mut tags = Vec::new(); - let mut due_date: Option = None; - - for token in rest.split_whitespace() { - if let Some(p) = token.strip_prefix("p:") { - if let Ok(v) = p.parse::() { - if (1..=3).contains(&v) { - priority = v; - } - } - } else if let Some(value) = token.strip_prefix("due:") { - match due::parse_due_date(value) { - Some(millis) => due_date = Some(millis), - None => { - return Err(ParseError { - message: format!( - "Invalid due date: {value} (use YYYY-MM-DD, today, tomorrow)" - ), - }) - } - } - } else if token.starts_with('+') && token.len() > 1 { - let tag = token[1..].to_lowercase(); - if !tags.iter().any(|t: &String| t.eq_ignore_ascii_case(&tag)) { - tags.push(tag); - } - } else { - desc_parts.push(token); - } + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let mut parsed = + board::parse_item_input(&tokens).map_err(|e| ParseError::new(e.to_string()))?; + if let Some(name) = quoted { + parsed.boards.insert(0, name); } + Ok(parsed) +} - let description = desc_parts.join(" "); - if description.is_empty() { - return Err(ParseError { - message: "Task description cannot be empty".to_string(), - }); +fn parse_task(args: &str) -> Result { + if args.trim().is_empty() { + return Err(ParseError::new( + "Usage: /task [@board] description [+tag] [p:1-3] [due:YYYY-MM-DD[THH:MM]]", + )); + } + let parsed = parse_item_args(args)?; + if parsed.description.is_empty() { + return Err(ParseError::new("Task description cannot be empty")); } - Ok(ParsedCommand::Task { - board, - description, - priority, - tags, - due_date, + boards: parsed.boards, + description: parsed.description, + priority: parsed.priority.unwrap_or_default(), + tags: parsed.tags, + due_date: parsed.due_date, }) } fn parse_note(args: &str) -> Result { - let args = args.trim(); - if args.is_empty() { - return Err(ParseError { - message: "Usage: /note [@board] title".to_string(), - }); + if args.trim().is_empty() { + return Err(ParseError::new("Usage: /note [@board] [+tag] title")); } - - let (board, rest) = if args.starts_with('@') { - match extract_at_board(args) { - Some((name, remaining)) => (Some(name), remaining.to_string()), - None => (None, args.to_string()), - } - } else { - (None, args.to_string()) - }; - - let mut desc_parts = Vec::new(); - let mut tags = Vec::new(); - - for token in rest.split_whitespace() { - if token.starts_with('+') && token.len() > 1 { - let tag = token[1..].to_lowercase(); - if !tags.iter().any(|t: &String| t.eq_ignore_ascii_case(&tag)) { - tags.push(tag); - } - } else { - desc_parts.push(token); - } + let parsed = parse_item_args(args)?; + if parsed.priority.is_some() { + return Err(ParseError::new("Notes cannot have a priority")); } - - let description = desc_parts.join(" "); - if description.is_empty() { - return Err(ParseError { - message: "Note title cannot be empty".to_string(), - }); + if parsed.due_date.is_some() { + return Err(ParseError::new("Notes cannot have a due date")); + } + if parsed.description.is_empty() { + return Err(ParseError::new("Note title cannot be empty")); } - Ok(ParsedCommand::Note { - board, - description, - tags, + boards: parsed.boards, + description: parsed.description, + tags: parsed.tags, }) } -fn parse_edit(args: &str) -> Result { +/// Split off the first whitespace-separated token as an id. +fn split_id(args: &str, usage: &str) -> Result<(u64, String), ParseError> { let args = args.trim(); - // Expect @ - let mut tokens = args.splitn(2, ' '); - let id_token = tokens.next().unwrap_or(""); - let desc = tokens.next().unwrap_or("").trim(); - - let id = parse_at_id(id_token)?; - if desc.is_empty() { - return Err(ParseError { - message: "Usage: /edit @ ".to_string(), - }); + let (id_token, rest) = args.split_once(char::is_whitespace).unwrap_or((args, "")); + if id_token.is_empty() { + return Err(ParseError::new(usage)); } + Ok((id::parse_id(id_token)?, rest.trim().to_string())) +} - Ok(ParsedCommand::Edit { - id, - description: desc.to_string(), - }) +fn parse_edit(args: &str) -> Result { + const USAGE: &str = "Usage: /edit "; + let (id, description) = split_id(args, USAGE)?; + if description.is_empty() { + return Err(ParseError::new(USAGE)); + } + Ok(ParsedCommand::Edit { id, description }) } fn parse_move(args: &str) -> Result { - let args = args.trim(); - - // Extract the ID (first token) - let id_end = args.find(char::is_whitespace).ok_or(ParseError { - message: "Usage: /move @ @".to_string(), - })?; - - let id_token = &args[..id_end]; - let rest = args[id_end..].trim(); - - let id = parse_at_id(id_token)?; - + const USAGE: &str = "Usage: /move @"; + let (id, rest) = split_id(args, USAGE)?; if rest.is_empty() { - return Err(ParseError { - message: "Usage: /move @ @".to_string(), - }); - } - - // Extract board name (supports @"quoted name") - let board = if rest.starts_with('@') { - match extract_at_board(rest) { - Some((name, _)) => name, - None => { - return Err(ParseError { - message: "Board name cannot be empty".to_string(), - }) - } - } - } else { - // Unquoted, no @ prefix — take first word - rest.split_whitespace().next().unwrap_or("").to_string() + return Err(ParseError::new(USAGE)); + } + + // Board name: @"quoted name", @word, or a bare word + let board = match extract_at_board(&rest) { + Some((name, _)) => name, + None => rest + .trim_start_matches('@') + .split_whitespace() + .next() + .unwrap_or("") + .to_string(), }; - if board.is_empty() { - return Err(ParseError { - message: "Board name cannot be empty".to_string(), - }); + return Err(ParseError::new("Board name cannot be empty")); } Ok(ParsedCommand::Move { id, board }) } fn parse_priority(args: &str) -> Result { - let args = args.trim(); - let tokens: Vec<&str> = args.split_whitespace().collect(); - if tokens.len() < 2 { - return Err(ParseError { - message: "Usage: /priority @ <1-3>".to_string(), - }); - } - - let id = parse_at_id(tokens[0])?; - let level = tokens[1].parse::().map_err(|_| ParseError { - message: "Priority must be 1, 2, or 3".to_string(), - })?; - - if !(1..=3).contains(&level) { - return Err(ParseError { - message: "Priority must be 1, 2, or 3".to_string(), - }); - } - + const USAGE: &str = "Usage: /priority <1-3> (1 = high, 3 = normal)"; + let (id, level) = split_id(args, USAGE)?; + if level.is_empty() { + return Err(ParseError::new(USAGE)); + } + let level = level + .parse::() + .map_err(|e| ParseError::new(e.to_string()))?; Ok(ParsedCommand::Priority { id, level }) } @@ -414,73 +345,32 @@ fn extract_at_board(input: &str) -> Option<(String, &str)> { } fn parse_tag(args: &str) -> Result { - let args = args.trim(); - if args.is_empty() { - return Err(ParseError { - message: "Usage: /tag @ +tag1 +tag2 -tag3".to_string(), - }); - } - - let tokens: Vec<&str> = args.split_whitespace().collect(); - if tokens.len() < 2 { - return Err(ParseError { - message: "Usage: /tag @ +tag1 -tag2".to_string(), - }); + const USAGE: &str = "Usage: /tag +tag1 +tag2 -tag3"; + let (id, rest) = split_id(args, USAGE)?; + if rest.is_empty() { + return Err(ParseError::new(USAGE)); } - let id = parse_at_id(tokens[0])?; let mut add = Vec::new(); let mut remove = Vec::new(); - - for token in &tokens[1..] { - if let Some(tag) = token.strip_prefix('+') { - let normalized = tag.trim().to_lowercase(); - if !normalized.is_empty() { - add.push(normalized); - } - } else if let Some(tag) = token.strip_prefix('-') { - let normalized = tag.trim().to_lowercase(); - if !normalized.is_empty() { - remove.push(normalized); - } + for token in rest.split_whitespace() { + if let Some(tag) = token.strip_prefix('+').filter(|t| !t.is_empty()) { + add.push(board::normalize_tag(tag)); + } else if let Some(tag) = token.strip_prefix('-').filter(|t| !t.is_empty()) { + remove.push(board::normalize_tag(tag)); + } else { + return Err(ParseError::new(format!( + "Expected +tag or -tag, got '{token}'" + ))); } } - if add.is_empty() && remove.is_empty() { - return Err(ParseError { - message: "Use +tag to add or -tag to remove".to_string(), - }); - } - Ok(ParsedCommand::Tag { id, add, remove }) } -fn parse_at_id(token: &str) -> Result { - let num_str = token.strip_prefix('@').unwrap_or(token); - - num_str.parse::().map_err(|_| ParseError { - message: format!("Invalid item ID: {}", token), - }) -} - fn parse_id_list(args: &str) -> Result, ParseError> { - let args = args.trim(); - if args.is_empty() { - return Err(ParseError { - message: "At least one ID is required".to_string(), - }); - } - - let mut ids = Vec::new(); - for token in args.split_whitespace() { - let num_str = token.strip_prefix('@').unwrap_or(token); - let id = num_str.parse::().map_err(|_| ParseError { - message: format!("Invalid ID: {}", token), - })?; - ids.push(id); - } - - Ok(ids) + let tokens: Vec<&str> = args.split_whitespace().collect(); + Ok(id::parse_ids(&tokens)?) } #[cfg(test)] @@ -535,14 +425,14 @@ mod tests { let result = parse_command("/task @\"MiST: IT-Leder\" Fix the bug").unwrap(); match result { ParsedCommand::Task { - board, + boards, description, priority, .. } => { - assert_eq!(board.as_deref(), Some("MiST: IT-Leder")); + assert_eq!(boards, ["MiST: IT-Leder"]); assert_eq!(description, "Fix the bug"); - assert_eq!(priority, 1); + assert_eq!(priority, Priority::Normal); } _ => panic!("Expected Task"), } @@ -553,14 +443,14 @@ mod tests { let result = parse_command("/task @\"Dev Ops\" Deploy service p:3").unwrap(); match result { ParsedCommand::Task { - board, + boards, description, priority, .. } => { - assert_eq!(board.as_deref(), Some("Dev Ops")); + assert_eq!(boards, ["Dev Ops"]); assert_eq!(description, "Deploy service"); - assert_eq!(priority, 3); + assert_eq!(priority, Priority::Normal); } _ => panic!("Expected Task"), } @@ -571,9 +461,11 @@ mod tests { let result = parse_command("/task @coding Fix bug").unwrap(); match result { ParsedCommand::Task { - board, description, .. + boards, + description, + .. } => { - assert_eq!(board.as_deref(), Some("coding")); + assert_eq!(boards, ["coding"]); assert_eq!(description, "Fix bug"); } _ => panic!("Expected Task"), @@ -585,9 +477,11 @@ mod tests { let result = parse_command("/note @\"MiST: IT-Leder\" Important note").unwrap(); match result { ParsedCommand::Note { - board, description, .. + boards, + description, + .. } => { - assert_eq!(board.as_deref(), Some("MiST: IT-Leder")); + assert_eq!(boards, ["MiST: IT-Leder"]); assert_eq!(description, "Important note"); } _ => panic!("Expected Note"), @@ -596,7 +490,7 @@ mod tests { #[test] fn test_parse_move_quoted_board() { - let result = parse_command("/move @1 @\"MiST: IT-Leder\"").unwrap(); + let result = parse_command("/move 1 @\"MiST: IT-Leder\"").unwrap(); match result { ParsedCommand::Move { id, board } => { assert_eq!(id, 1); @@ -628,7 +522,7 @@ mod tests { .. } => { assert_eq!(description, "Pay rent"); - assert_eq!(due_date, due::parse_due_date("2026-07-01")); + assert_eq!(due_date, taskbook_common::due::parse_due_date("2026-07-01")); } _ => panic!("expected Task"), } @@ -637,7 +531,7 @@ mod tests { #[test] fn test_parse_task_invalid_due_date_errors() { let err = parse_command("/task Pay rent due:whenever").unwrap_err(); - assert!(err.message.contains("Invalid due date")); + assert!(err.message.contains("invalid due date"), "{}", err.message); } #[test] @@ -645,12 +539,75 @@ mod tests { let result = parse_command("/task Simple task").unwrap(); match result { ParsedCommand::Task { - board, description, .. + boards, + description, + .. } => { - assert_eq!(board, None); + assert!(boards.is_empty()); assert_eq!(description, "Simple task"); } _ => panic!("Expected Task"), } } + + #[test] + fn test_ids_are_bare_numbers() { + assert!(matches!( + parse_command("/check 1 2").unwrap(), + ParsedCommand::Check { ids } if ids == [1, 2] + )); + let err = parse_command("/check @1").unwrap_err(); + assert!(err.message.contains("use `1`"), "{}", err.message); + let err = parse_command("/edit @3 new text").unwrap_err(); + assert!(err.message.contains("use `3`"), "{}", err.message); + assert!(parse_command("/check 1 x").is_err()); + } + + #[test] + fn test_parse_priority_high_is_one() { + for input in ["/priority 3 1", "/priority 3 p1", "/priority 3 high"] { + assert!(matches!( + parse_command(input).unwrap(), + ParsedCommand::Priority { + id: 3, + level: Priority::High + } + )); + } + assert!(parse_command("/priority 3 4").is_err()); + } + + #[test] + fn test_parse_task_invalid_priority_errors() { + assert!(parse_command("/task Fix p:4").is_err()); + let ParsedCommand::Task { priority, .. } = parse_command("/task Fix p:1").unwrap() else { + panic!("expected Task"); + }; + assert_eq!(priority, Priority::High); + } + + #[test] + fn test_parse_task_multiple_boards() { + let ParsedCommand::Task { boards, .. } = + parse_command("/task @\"Dev Ops\" @infra Deploy").unwrap() + else { + panic!("expected Task"); + }; + assert_eq!(boards, ["Dev Ops", "infra"]); + } + + #[test] + fn test_parse_note_rejects_task_fields() { + assert!(parse_command("/note Idea p:1").is_err()); + assert!(parse_command("/note Idea due:today").is_err()); + } + + #[test] + fn test_parse_tag_rejects_stray_words() { + assert!(parse_command("/tag 3 +a b").is_err()); + assert!(matches!( + parse_command("/tag 3 +A -b").unwrap(), + ParsedCommand::Tag { id: 3, ref add, ref remove } if add == &["a"] && remove == &["b"] + )); + } } diff --git a/crates/taskbook-client/src/tui/mod.rs b/crates/taskbook-client/src/tui/mod.rs index 55533c3..fd2d0d1 100644 --- a/crates/taskbook-client/src/tui/mod.rs +++ b/crates/taskbook-client/src/tui/mod.rs @@ -160,7 +160,7 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> use app::ViewMode; match (app.view, archived) { (ViewMode::Archive, true) => { - app.items = app.taskbook.get_all_archive_items()?; + app.items = app.taskbook.archived_items()?; app.update_display_order(); } (ViewMode::Board | ViewMode::Timeline | ViewMode::Journal, false) => { diff --git a/crates/taskbook-client/src/tui/widgets/help_popup.rs b/crates/taskbook-client/src/tui/widgets/help_popup.rs index da2e69d..f18e994 100644 --- a/crates/taskbook-client/src/tui/widgets/help_popup.rs +++ b/crates/taskbook-client/src/tui/widgets/help_popup.rs @@ -115,21 +115,21 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { ]), Line::from(vec![ Span::styled(" e ", key_style), - Span::styled("→ /edit @ ", desc_style), + Span::styled("→ /edit ", desc_style), ]), Line::from(vec![ Span::styled(" m ", key_style), - Span::styled("→ /move @ @...", desc_style), + Span::styled("→ /move @...", desc_style), ]), Line::from(vec![ Span::styled(" p ", key_style), - Span::styled("→ /priority @ ...", desc_style), + Span::styled("→ /priority ...", desc_style), ]), Line::from(""), Line::from(Span::styled(" Slash Commands", section_style)), Line::from(vec![ Span::styled(" /task ", cmd_style), - Span::styled("@board +tag Description p:2", desc_style), + Span::styled("@board +tag Description p:1-3", desc_style), ]), Line::from(vec![ Span::styled(" ", cmd_style), @@ -141,11 +141,15 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { ]), Line::from(vec![ Span::styled(" /edit ", cmd_style), - Span::styled("@ New description", desc_style), + Span::styled(" New description", desc_style), ]), Line::from(vec![ Span::styled(" /move ", cmd_style), - Span::styled("@ @board", desc_style), + Span::styled(" @board", desc_style), + ]), + Line::from(vec![ + Span::styled(" /priority ", cmd_style), + Span::styled(" <1-3> (1 = high, 3 = normal)", desc_style), ]), Line::from(vec![ Span::styled(" /delete ", cmd_style), @@ -157,7 +161,7 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { ]), Line::from(vec![ Span::styled(" /tag ", cmd_style), - Span::styled("@ +add -remove", desc_style), + Span::styled(" +add -remove", desc_style), ]), Line::from(vec![ Span::styled(" /clear ", cmd_style), diff --git a/crates/taskbook-client/src/tui/widgets/item_row.rs b/crates/taskbook-client/src/tui/widgets/item_row.rs index b39f8ca..2831a00 100644 --- a/crates/taskbook-client/src/tui/widgets/item_row.rs +++ b/crates/taskbook-client/src/tui/widgets/item_row.rs @@ -6,7 +6,7 @@ use ratatui::{ use crate::tui::app::App; use taskbook_common::board; use taskbook_common::due; -use taskbook_common::StorageItem; +use taskbook_common::{Priority, StorageItem}; /// Options for rendering an item row pub struct ItemRowOptions { @@ -65,9 +65,9 @@ pub fn render_item_line( let desc_style = if let Some(task) = item.as_task() { if task.is_complete { app.theme.completed_text - } else if task.priority == 3 { + } else if task.priority == Priority::High { app.theme.error.add_modifier(Modifier::BOLD) - } else if task.priority == 2 { + } else if task.priority == Priority::Medium { app.theme.warning } else { Style::default().fg(Color::White) @@ -114,9 +114,9 @@ pub fn render_item_line( // Priority indicator if let Some(task) = item.as_task() { - if task.priority == 2 { + if task.priority == Priority::Medium { spans.push(Span::styled(" (!)", app.theme.warning)); - } else if task.priority == 3 { + } else if task.priority == Priority::High { spans.push(Span::styled(" (!!)", app.theme.error)); } } diff --git a/crates/taskbook-common/src/board.rs b/crates/taskbook-common/src/board.rs index 5a0b061..f9dabcd 100644 --- a/crates/taskbook-common/src/board.rs +++ b/crates/taskbook-common/src/board.rs @@ -3,6 +3,9 @@ //! Board names are stored **without** the `@` prefix. The `@` is added back //! only at the display layer via [`display_name`]. +use crate::due; +use crate::priority::{ParsePriorityError, Priority}; + /// The default board name used when no board is specified. pub const DEFAULT_BOARD: &str = "My Board"; @@ -41,78 +44,87 @@ pub fn display_name(board: &str) -> String { } } -/// Structured result of parsing CLI create input. +/// Structured result of parsing item-creation input. #[derive(Debug)] pub struct ParsedInput { + /// Boards named with `@`. Empty from [`parse_item_input`] when none were + /// given; [`parse_cli_input`] fills in [`DEFAULT_BOARD`]. pub boards: Vec, pub description: String, - pub priority: u8, + /// Priority from a `p:` token, if one was given. + pub priority: Option, pub tags: Vec, - /// Due date as local-midnight epoch millis (from a `due:` token). + /// Due date as epoch millis (from a `due:` token). pub due_date: Option, } -/// Errors from parsing CLI create input. +/// Errors from parsing item-creation input. #[derive(Debug, PartialEq, Eq)] pub enum CliParseError { /// The value after `due:` was not a valid date. InvalidDueDate(String), + /// A `p:` token did not name a valid priority. + InvalidPriority(ParsePriorityError), } -/// Parse CLI input words into a [`ParsedInput`]. -/// -/// Words starting with `@` (and longer than 1 char) are treated as board names. -/// Words starting with `+` (and longer than 1 char) are treated as tags. -/// Words matching `p:1`, `p:2`, `p:3` set priority. -/// Words starting with `due:` set the due date (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, -/// `today`, `tomorrow`, `now`, or `today+HHMM` / `tomorrow+HHMM` / `now+HHMM`). -/// (A `due:` token cannot contain a space, so use the `T` separator for a time.) -/// Everything else is the description. +impl std::fmt::Display for CliParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliParseError::InvalidDueDate(raw) => { + write!(f, "{}", due::invalid_due_date_message(raw)) + } + CliParseError::InvalidPriority(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for CliParseError {} + +/// Parse item-creation words into a [`ParsedInput`], leaving `boards` empty +/// when none are named. /// -/// If no boards are found, defaults to [`DEFAULT_BOARD`]. -pub fn parse_cli_input(input: &[String]) -> Result { - let mut boards = Vec::new(); - let mut tags = Vec::new(); +/// - `@name` (longer than 1 char) names a board. +/// - `+name` (longer than 1 char) adds a tag. +/// - `p:N` sets priority (`p:1` = high, `p:2` = medium, `p:3` = normal; the +/// labels `p:high` etc. also work). Any other `p:` value is an error. +/// - `due:VALUE` sets the due date (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, +/// `today`, `tomorrow`, `now`, or `today+HHMM` / `tomorrow+HHMM` / +/// `now+HHMM`). A `due:` token cannot contain a space, so use the `T` +/// separator for a time. +/// - Everything else is the description. +pub fn parse_item_input>(input: &[S]) -> Result { + let mut boards: Vec = Vec::new(); + let mut tags: Vec = Vec::new(); let mut desc = Vec::new(); - let mut priority: u8 = 1; + let mut priority = None; let mut due_date: Option = None; for word in input { - if is_priority_opt(word) { - if let Ok(p) = word.trim_start_matches("p:").parse::() { - priority = p; - } + let word = word.as_ref(); + if word.starts_with("p:") { + priority = Some(word.parse().map_err(CliParseError::InvalidPriority)?); } else if let Some(value) = word.strip_prefix("due:") { - match crate::due::parse_due_date(value) { + match due::parse_due_date(value) { Some(millis) => due_date = Some(millis), None => return Err(CliParseError::InvalidDueDate(value.to_string())), } } else if word.starts_with('@') && word.len() > 1 { - boards.push(normalize_board_name(word)); + let board = normalize_board_name(word); + if !boards.iter().any(|b| board_eq(b, &board)) { + boards.push(board); + } } else if word.starts_with('+') && word.len() > 1 { let tag = normalize_tag(word); - if !tags.iter().any(|t: &String| t.eq_ignore_ascii_case(&tag)) { + if !tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) { tags.push(tag); } } else { - desc.push(word.clone()); - } - } - - if boards.is_empty() { - boards.push(DEFAULT_BOARD.to_string()); - } - - // Deduplicate boards (case-insensitive) - let mut deduped: Vec = Vec::new(); - for board in boards { - if !deduped.iter().any(|b| board_eq(b, &board)) { - deduped.push(board); + desc.push(word); } } Ok(ParsedInput { - boards: deduped, + boards, description: desc.join(" "), priority, tags, @@ -120,6 +132,16 @@ pub fn parse_cli_input(input: &[String]) -> Result { }) } +/// Like [`parse_item_input`], defaulting to [`DEFAULT_BOARD`] when no board +/// is named. +pub fn parse_cli_input>(input: &[S]) -> Result { + let mut parsed = parse_item_input(input)?; + if parsed.boards.is_empty() { + parsed.boards.push(DEFAULT_BOARD.to_string()); + } + Ok(parsed) +} + /// Normalize a raw tag name: strip leading `+`, trim whitespace, lowercase. pub fn normalize_tag(raw: &str) -> String { raw.trim().trim_start_matches('+').trim().to_lowercase() @@ -130,10 +152,6 @@ pub fn display_tag(tag: &str) -> String { format!("+{}", tag) } -fn is_priority_opt(s: &str) -> bool { - matches!(s, "p:1" | "p:2" | "p:3") -} - /// Deserialize a list of board names, normalizing each one. /// /// Used as `#[serde(deserialize_with = "...")]` on the `boards` field @@ -204,7 +222,7 @@ mod tests { let parsed = parse_cli_input(&input).unwrap(); assert_eq!(parsed.boards, vec!["coding"]); assert_eq!(parsed.description, "Fix bug"); - assert_eq!(parsed.priority, 1); + assert_eq!(parsed.priority, None); assert!(parsed.tags.is_empty()); assert_eq!(parsed.due_date, None); } @@ -215,7 +233,7 @@ mod tests { let parsed = parse_cli_input(&input).unwrap(); assert_eq!(parsed.boards, vec!["coding"]); assert_eq!(parsed.description, "Fix bug"); - assert_eq!(parsed.priority, 3); + assert_eq!(parsed.priority, Some(Priority::Normal)); } #[test] @@ -224,7 +242,7 @@ mod tests { let parsed = parse_cli_input(&input).unwrap(); assert_eq!(parsed.boards, vec![DEFAULT_BOARD]); assert_eq!(parsed.description, "Simple task"); - assert_eq!(parsed.priority, 1); + assert_eq!(parsed.priority, None); } #[test] @@ -240,8 +258,31 @@ mod tests { for p in 1..=3u8 { let input: Vec = vec!["task".into(), format!("p:{p}")]; let parsed = parse_cli_input(&input).unwrap(); - assert_eq!(parsed.priority, p, "expected priority {p}"); + assert_eq!( + parsed.priority, + Priority::from_level(p), + "expected priority {p}" + ); } + let parsed = parse_cli_input(&["task", "p:high"]).unwrap(); + assert_eq!(parsed.priority, Some(Priority::High)); + } + + #[test] + fn test_parse_cli_input_invalid_priority_errors() { + for bad in ["p:4", "p:0", "p:", "p:urgent"] { + let err = parse_cli_input(&["task", bad]).unwrap_err(); + assert!( + matches!(err, CliParseError::InvalidPriority(_)), + "{bad} should be rejected" + ); + } + } + + #[test] + fn test_parse_item_input_leaves_boards_empty() { + let parsed = parse_item_input(&["task"]).unwrap(); + assert!(parsed.boards.is_empty()); } #[test] diff --git a/crates/taskbook-common/src/due.rs b/crates/taskbook-common/src/due.rs index cb687b7..705a01b 100644 --- a/crates/taskbook-common/src/due.rs +++ b/crates/taskbook-common/src/due.rs @@ -15,6 +15,14 @@ pub enum DueStatus { Upcoming, } +/// The user-facing error for a due-date string [`parse_due_date`] rejects. +pub fn invalid_due_date_message(raw: &str) -> String { + format!( + "invalid due date '{raw}' (use YYYY-MM-DD, \"YYYY-MM-DD HH:MM\", today, tomorrow, now, \ + or today/tomorrow/now+HHMM)" + ) +} + /// Parse a due-date string into epoch millis (local time). /// /// Accepted formats (case-insensitive for keywords): diff --git a/crates/taskbook-common/src/encryption.rs b/crates/taskbook-common/src/encryption.rs index cb7ef48..c73ab18 100644 --- a/crates/taskbook-common/src/encryption.rs +++ b/crates/taskbook-common/src/encryption.rs @@ -58,7 +58,7 @@ pub fn decrypt_item(key: &[u8; 32], encrypted: &EncryptedItem) -> Result` for some commands. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdError { + /// No id was given where at least one is required. + Missing, + /// The token is not a bare number. + Invalid(String), + /// The token is `@`: the 1.x id syntax. + AtPrefixed(String), +} + +impl fmt::Display for IdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IdError::Missing => write!(f, "no item id given"), + IdError::Invalid(token) => write!(f, "invalid item id '{token}' (ids are numbers)"), + IdError::AtPrefixed(token) => write!( + f, + "invalid item id '{token}': ids are bare numbers and @ names a board; use `{}`", + &token[1..] + ), + } + } +} + +impl std::error::Error for IdError {} + +fn is_at_id(token: &str) -> bool { + token + .strip_prefix('@') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +/// Parse a single id token. +pub fn parse_id(token: &str) -> Result { + if is_at_id(token) { + return Err(IdError::AtPrefixed(token.to_string())); + } + token + .parse::() + .map_err(|_| IdError::Invalid(token.to_string())) +} + +/// Parse a list where every token must be an id. Fails on an empty list. +pub fn parse_ids>(tokens: &[S]) -> Result, IdError> { + if tokens.is_empty() { + return Err(IdError::Missing); + } + tokens.iter().map(|t| parse_id(t.as_ref())).collect() +} + +/// Split `... ...`: the leading run of bare-number tokens are ids, +/// everything from the first non-number on is returned as the rest. +/// +/// Fails when there is no leading id; if the first token is `@` the +/// error carries the 1.x-syntax hint. +pub fn split_leading_ids>(tokens: &[S]) -> Result<(Vec, &[S]), IdError> { + let count = tokens + .iter() + .take_while(|t| t.as_ref().parse::().is_ok()) + .count(); + if count == 0 { + return Err(match tokens.first() { + Some(t) if is_at_id(t.as_ref()) => IdError::AtPrefixed(t.as_ref().to_string()), + Some(t) => IdError::Invalid(t.as_ref().to_string()), + None => IdError::Missing, + }); + } + let ids = tokens[..count] + .iter() + .map(|t| t.as_ref().parse::().expect("checked above")) + .collect(); + Ok((ids, &tokens[count..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_id_accepts_bare_numbers() { + assert_eq!(parse_id("3"), Ok(3)); + } + + #[test] + fn test_parse_id_rejects_at_prefix_with_hint() { + let err = parse_id("@3").unwrap_err(); + assert_eq!(err, IdError::AtPrefixed("@3".into())); + assert!(err.to_string().contains("use `3`")); + } + + #[test] + fn test_parse_id_rejects_garbage() { + assert_eq!(parse_id("foo"), Err(IdError::Invalid("foo".into()))); + assert_eq!(parse_id("@foo"), Err(IdError::Invalid("@foo".into()))); + assert_eq!(parse_id("-1"), Err(IdError::Invalid("-1".into()))); + } + + #[test] + fn test_parse_ids_is_strict() { + assert_eq!(parse_ids(&["1", "2"]), Ok(vec![1, 2])); + assert_eq!(parse_ids(&["1", "x"]), Err(IdError::Invalid("x".into()))); + assert_eq!(parse_ids::<&str>(&[]), Err(IdError::Missing)); + } + + #[test] + fn test_split_leading_ids() { + let tokens = ["1", "2", "@work", "3"]; + let (ids, rest) = split_leading_ids(&tokens).unwrap(); + assert_eq!(ids, vec![1, 2]); + assert_eq!(rest, &["@work", "3"]); + } + + #[test] + fn test_split_leading_ids_requires_an_id() { + assert_eq!( + split_leading_ids(&["@3", "@work"]).unwrap_err(), + IdError::AtPrefixed("@3".into()) + ); + assert_eq!( + split_leading_ids(&["work"]).unwrap_err(), + IdError::Invalid("work".into()) + ); + assert_eq!( + split_leading_ids::<&str>(&[]).unwrap_err(), + IdError::Missing + ); + } +} diff --git a/crates/taskbook-common/src/lib.rs b/crates/taskbook-common/src/lib.rs index b2a7a4e..887313a 100644 --- a/crates/taskbook-common/src/lib.rs +++ b/crates/taskbook-common/src/lib.rs @@ -3,7 +3,10 @@ pub mod board; pub mod due; pub mod encryption; pub mod error; +pub mod id; pub mod models; +pub mod priority; pub use error::{CommonError, CommonResult}; pub use models::{Item, Note, StorageItem, Task}; +pub use priority::Priority; diff --git a/crates/taskbook-common/src/models/mod.rs b/crates/taskbook-common/src/models/mod.rs index 3bf7d35..d29264e 100644 --- a/crates/taskbook-common/src/models/mod.rs +++ b/crates/taskbook-common/src/models/mod.rs @@ -148,6 +148,13 @@ impl StorageItem { matches!(self, StorageItem::Task(_)) } + pub fn set_id(&mut self, id: u64) { + match self { + StorageItem::Task(t) => t.id = id, + StorageItem::Note(n) => n.id = id, + } + } + pub fn set_description(&mut self, desc: String) { match self { StorageItem::Task(t) => t.description = desc, diff --git a/crates/taskbook-common/src/models/task.rs b/crates/taskbook-common/src/models/task.rs index bba3ddc..9c1e380 100644 --- a/crates/taskbook-common/src/models/task.rs +++ b/crates/taskbook-common/src/models/task.rs @@ -2,48 +2,109 @@ use serde::{Deserialize, Serialize}; use super::item::Item; use crate::board; +use crate::priority::Priority; /// A task item with completion status and priority #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(from = "TaskRepr", into = "TaskRepr")] pub struct Task { - #[serde(rename = "_id")] pub id: u64, - - #[serde(rename = "_date")] pub date: String, - - #[serde(rename = "_timestamp")] pub timestamp: i64, - - #[serde(rename = "_isTask")] pub is_task_flag: bool, - pub description: String, - - #[serde(rename = "isStarred")] pub is_starred: bool, - - #[serde(rename = "isComplete")] pub is_complete: bool, - - #[serde(rename = "inProgress")] pub in_progress: bool, - - pub priority: u8, - - #[serde(rename = "_dueDate", default, skip_serializing_if = "Option::is_none")] + pub priority: Priority, + /// Due date as epoch millis. pub due_date: Option, - - #[serde(deserialize_with = "board::deserialize_boards")] pub boards: Vec, + pub tags: Vec, +} +/// On-disk / on-wire shape of a [`Task`]. +/// +/// Field names match the original Node.js taskbook. Priority is stored twice: +/// `_priority` (1 = high … 3 = normal) is authoritative; the legacy +/// `priority` field (1 = normal … 3 = high) is written for 1.x clients and +/// read only when `_priority` is absent. A 1.x client re-saving an item drops +/// `_priority` (it does not know the field), so the two never disagree. +#[derive(Serialize, Deserialize)] +struct TaskRepr { + #[serde(rename = "_id")] + id: u64, + #[serde(rename = "_date")] + date: String, + #[serde(rename = "_timestamp")] + timestamp: i64, + #[serde(rename = "_isTask")] + is_task_flag: bool, + description: String, + #[serde(rename = "isStarred")] + is_starred: bool, + #[serde(rename = "isComplete")] + is_complete: bool, + #[serde(rename = "inProgress")] + in_progress: bool, + #[serde(rename = "_priority", default, skip_serializing_if = "Option::is_none")] + priority: Option, + #[serde(rename = "priority", default, skip_serializing_if = "Option::is_none")] + legacy_priority: Option, + #[serde(rename = "_dueDate", default, skip_serializing_if = "Option::is_none")] + due_date: Option, + #[serde(deserialize_with = "board::deserialize_boards")] + boards: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, + tags: Vec, +} + +impl From for Task { + fn from(r: TaskRepr) -> Self { + let priority = match (r.priority, r.legacy_priority) { + (Some(p), _) => Priority::from_stored(p), + (None, Some(legacy)) => Priority::from_legacy(legacy), + (None, None) => Priority::default(), + }; + Self { + id: r.id, + date: r.date, + timestamp: r.timestamp, + is_task_flag: r.is_task_flag, + description: r.description, + is_starred: r.is_starred, + is_complete: r.is_complete, + in_progress: r.in_progress, + priority, + due_date: r.due_date, + boards: r.boards, + tags: r.tags, + } + } +} + +impl From for TaskRepr { + fn from(t: Task) -> Self { + Self { + id: t.id, + date: t.date, + timestamp: t.timestamp, + is_task_flag: t.is_task_flag, + description: t.description, + is_starred: t.is_starred, + is_complete: t.is_complete, + in_progress: t.in_progress, + priority: Some(t.priority.level()), + legacy_priority: Some(t.priority.to_legacy()), + due_date: t.due_date, + boards: t.boards, + tags: t.tags, + } + } } impl Task { - /// Creates a new task. The `priority` value is clamped silently to the range 1-3. - pub fn new(id: u64, description: String, boards: Vec, priority: u8) -> Self { + pub fn new(id: u64, description: String, boards: Vec, priority: Priority) -> Self { let now = chrono::Local::now(); Self { id, @@ -54,7 +115,7 @@ impl Task { is_starred: false, is_complete: false, in_progress: false, - priority: priority.clamp(1, 3), + priority, due_date: None, boards, tags: Vec::new(), @@ -67,17 +128,10 @@ impl Task { self } - /// Creates a new task with tags. - pub fn new_with_tags( - id: u64, - description: String, - boards: Vec, - priority: u8, - tags: Vec, - ) -> Self { - let mut task = Self::new(id, description, boards, priority); - task.tags = tags; - task + /// Returns the task with the given tags. + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self } } @@ -119,29 +173,22 @@ impl Item for Task { mod tests { use super::*; + const LEGACY_HIGH: &str = r#"{"_id":1,"_date":"Fri Jun 12 2026","_timestamp":1,"_isTask":true,"description":"x","isStarred":false,"isComplete":false,"inProgress":false,"priority":3,"boards":["My Board"]}"#; + + fn task(priority: Priority) -> Task { + Task::new(1, "T".to_string(), vec!["My Board".to_string()], priority) + } + #[test] fn test_task_is_task_uses_flag() { - let task = Task::new(1, "Test".to_string(), vec!["My Board".to_string()], 1); + let task = task(Priority::Normal); assert!(task.is_task()); assert!(task.is_task_flag); } - #[test] - fn test_priority_clamped_to_range() { - let low = Task::new(1, "Test".to_string(), vec!["My Board".to_string()], 0); - assert_eq!(low.priority, 1); - - let high = Task::new(2, "Test".to_string(), vec!["My Board".to_string()], 255); - assert_eq!(high.priority, 3); - - let mid = Task::new(3, "Test".to_string(), vec!["My Board".to_string()], 2); - assert_eq!(mid.priority, 2); - } - #[test] fn test_due_date_serde_round_trip() { - let task = Task::new(1, "T".to_string(), vec!["My Board".to_string()], 1) - .with_due_date(Some(1_790_000_000_000)); + let task = task(Priority::Normal).with_due_date(Some(1_790_000_000_000)); let json = serde_json::to_string(&task).unwrap(); assert!(json.contains("_dueDate")); let back: Task = serde_json::from_str(&json).unwrap(); @@ -150,12 +197,41 @@ mod tests { #[test] fn test_task_without_due_date_omits_field_and_deserializes() { - let task = Task::new(1, "T".to_string(), vec!["My Board".to_string()], 1); - let json = serde_json::to_string(&task).unwrap(); + let json = serde_json::to_string(&task(Priority::Normal)).unwrap(); assert!(!json.contains("_dueDate")); - // Legacy JSON without the field still parses - let legacy = r#"{"_id":1,"_date":"Fri Jun 12 2026","_timestamp":1,"_isTask":true,"description":"x","isStarred":false,"isComplete":false,"inProgress":false,"priority":1,"boards":["My Board"]}"#; - let back: Task = serde_json::from_str(legacy).unwrap(); + let back: Task = serde_json::from_str(LEGACY_HIGH).unwrap(); assert_eq!(back.due_date, None); } + + #[test] + fn test_legacy_priority_field_is_inverted_on_read() { + let back: Task = serde_json::from_str(LEGACY_HIGH).unwrap(); + assert_eq!(back.priority, Priority::High); + let normal = LEGACY_HIGH.replace(r#""priority":3"#, r#""priority":1"#); + let back: Task = serde_json::from_str(&normal).unwrap(); + assert_eq!(back.priority, Priority::Normal); + } + + #[test] + fn test_missing_priority_defaults_to_normal() { + let none = LEGACY_HIGH.replace(r#""priority":3,"#, ""); + let back: Task = serde_json::from_str(&none).unwrap(); + assert_eq!(back.priority, Priority::Normal); + } + + #[test] + fn test_new_priority_field_wins_over_legacy() { + let both = LEGACY_HIGH.replace(r#""priority":3"#, r#""priority":3,"_priority":2"#); + let back: Task = serde_json::from_str(&both).unwrap(); + assert_eq!(back.priority, Priority::Medium); + } + + #[test] + fn test_serialize_writes_both_priority_fields() { + let value = serde_json::to_value(task(Priority::High)).unwrap(); + assert_eq!(value["_priority"], 1); + assert_eq!(value["priority"], 3); + let back: Task = serde_json::from_value(value).unwrap(); + assert_eq!(back.priority, Priority::High); + } } diff --git a/crates/taskbook-common/src/priority.rs b/crates/taskbook-common/src/priority.rs new file mode 100644 index 0000000..70bdd77 --- /dev/null +++ b/crates/taskbook-common/src/priority.rs @@ -0,0 +1,166 @@ +//! Task priority. +//! +//! Levels count down from the most urgent: `1` = high, `2` = medium, +//! `3` = normal (the default). This is the scale used everywhere a user or +//! agent sees a number (CLI, TUI, MCP) and in the stored `_priority` field. +//! +//! Taskbook 1.x and the original Node.js taskbook stored the inverse scale +//! in the `priority` field (1 = normal … 3 = high). Tasks still write that +//! legacy field alongside `_priority` so 1.x clients sharing the same data +//! read the right value, and it is read back when `_priority` is absent. + +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum Priority { + High = 1, + Medium = 2, + #[default] + Normal = 3, +} + +impl Priority { + /// The user-facing level: 1 = high, 2 = medium, 3 = normal. + pub fn level(self) -> u8 { + self as u8 + } + + pub fn from_level(level: u8) -> Option { + match level { + 1 => Some(Priority::High), + 2 => Some(Priority::Medium), + 3 => Some(Priority::Normal), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Priority::High => "high", + Priority::Medium => "medium", + Priority::Normal => "normal", + } + } + + /// Decode a stored `_priority` value, clamping out-of-range values. + pub(crate) fn from_stored(level: u8) -> Self { + Self::from_level(level.clamp(1, 3)).unwrap_or_default() + } + + /// Decode the legacy (1.x / Node.js) `priority` field, where larger is + /// more urgent. Mirrors 1.x rendering: 2 is medium, anything above is high. + pub(crate) fn from_legacy(value: u8) -> Self { + match value { + 0 | 1 => Priority::Normal, + 2 => Priority::Medium, + _ => Priority::High, + } + } + + /// Encode for the legacy `priority` field. + pub(crate) fn to_legacy(self) -> u8 { + 4 - self.level() + } +} + +impl fmt::Display for Priority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "p{} ({})", self.level(), self.label()) + } +} + +/// Error returned when a priority string cannot be parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsePriorityError(pub String); + +impl fmt::Display for ParsePriorityError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid priority '{}' (use 1/high, 2/medium or 3/normal)", + self.0 + ) + } +} + +impl std::error::Error for ParsePriorityError {} + +impl FromStr for Priority { + type Err = ParsePriorityError; + + /// Accepts `1`-`3`, optionally prefixed with `p` or `p:`, or the labels + /// `high`/`medium`/`normal` (case-insensitive). + fn from_str(s: &str) -> Result { + let lower = s.trim().to_ascii_lowercase(); + let bare = lower + .strip_prefix("p:") + .or_else(|| lower.strip_prefix('p')) + .unwrap_or(&lower); + let parsed = match bare { + "high" => Some(Priority::High), + "medium" => Some(Priority::Medium), + "normal" => Some(Priority::Normal), + digits => digits.parse::().ok().and_then(Priority::from_level), + }; + parsed.ok_or_else(|| ParsePriorityError(s.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_levels_count_down_from_high() { + assert_eq!(Priority::High.level(), 1); + assert_eq!(Priority::Medium.level(), 2); + assert_eq!(Priority::Normal.level(), 3); + assert_eq!(Priority::default(), Priority::Normal); + } + + #[test] + fn test_ordering_sorts_high_first() { + let mut v = vec![Priority::Normal, Priority::High, Priority::Medium]; + v.sort(); + assert_eq!(v, vec![Priority::High, Priority::Medium, Priority::Normal]); + } + + #[test] + fn test_parse_accepts_numbers_prefixes_and_labels() { + for s in ["1", "p1", "p:1", "P:1", "high", "HIGH"] { + assert_eq!(s.parse::(), Ok(Priority::High), "{s}"); + } + assert_eq!("2".parse::(), Ok(Priority::Medium)); + assert_eq!("p:3".parse::(), Ok(Priority::Normal)); + assert_eq!("normal".parse::(), Ok(Priority::Normal)); + } + + #[test] + fn test_parse_rejects_out_of_range_and_garbage() { + for s in ["0", "4", "p:4", "p:", "", "urgent", "-1"] { + assert!(s.parse::().is_err(), "{s}"); + } + } + + #[test] + fn test_legacy_round_trip_inverts_scale() { + assert_eq!(Priority::from_legacy(1), Priority::Normal); + assert_eq!(Priority::from_legacy(2), Priority::Medium); + assert_eq!(Priority::from_legacy(3), Priority::High); + for p in [Priority::High, Priority::Medium, Priority::Normal] { + assert_eq!(Priority::from_legacy(p.to_legacy()), p); + } + } + + #[test] + fn test_from_stored_clamps() { + assert_eq!(Priority::from_stored(0), Priority::High); + assert_eq!(Priority::from_stored(9), Priority::Normal); + } + + #[test] + fn test_display() { + assert_eq!(Priority::High.to_string(), "p1 (high)"); + } +} From 6bccf94a2495b70b7f30098f7e29f1f766420d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:21:29 +0000 Subject: [PATCH 2/4] Document the 2.0 CLI and add an upgrade guide Rewrites docs/cli-reference.md for subcommands (also fixing two stale claims: clear archives rather than deleting, and move has no board picker), adds docs/upgrading-to-2.0.md, and updates README, CLAUDE.md and the remaining docs to the new syntax. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014rB6uhLWQEZyDE3XSNsyTZ --- CLAUDE.md | 92 +++++---- README.md | 60 +++--- docs/README.md | 9 +- docs/cli-reference.md | 400 ++++++++++++++++++++------------------- docs/configuration.md | 2 +- docs/installation.md | 2 +- docs/sync.md | 20 +- docs/upgrading-to-2.0.md | 100 ++++++++++ 8 files changed, 406 insertions(+), 279 deletions(-) create mode 100644 docs/upgrading-to-2.0.md diff --git a/CLAUDE.md b/CLAUDE.md index 9202098..4f7eda6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,13 +51,16 @@ crates/ │ ├── due.rs # Due-date parsing/formatting/status (epoch millis) │ ├── encryption.rs # AES-256-GCM encrypt/decrypt │ ├── error.rs # CommonError type +│ ├── id.rs # Item id grammar (bare numbers; @ is a board) +│ ├── priority.rs # Priority enum (1 = high), legacy-field mapping │ └── models/ # StorageItem, Task, Note, Item trait │ ├── taskbook-client/ # CLI + TUI binary (tb) │ └── src/ -│ ├── main.rs # CLI entry point using clap -│ ├── commands.rs # Routes CLI flags to taskbook methods + migrate -│ ├── taskbook.rs # Core business logic (CRUD operations) +│ ├── main.rs # Entry point: legacy-flag shim, parse, print errors once +│ ├── cli.rs # clap subcommand definitions + 1.x flag rewriting +│ ├── commands.rs # Runs subcommands: parse args, call Taskbook, render + migrate +│ ├── taskbook.rs # Core business logic (validated, all-or-nothing, no output) │ ├── api_client.rs # HTTP client for server communication │ ├── auth.rs # Register, login, logout, status commands │ ├── credentials.rs # Encryption key and token management @@ -103,7 +106,7 @@ crates/ 2. **Client-Side Encryption**: All data is encrypted with AES-256-GCM before being sent to the server. The 32-byte encryption key is generated on registration and never leaves the client. Each item is encrypted individually with a unique random nonce. -3. **Backward Compatible JSON Format**: Uses `#[serde(rename = "...")]` to match the original Node.js field names (`_id`, `_date`, `_isTask`, `isStarred`, etc.) for seamless data migration. +3. **Backward Compatible JSON Format**: Uses `#[serde(rename = "...")]` to match the original Node.js field names (`_id`, `_date`, `_isTask`, `isStarred`, etc.) for seamless data migration. Priority is the exception: the authoritative field is `_priority` (1 = high, 2 = medium, 3 = normal); the legacy `priority` field (1 = normal … 3 = high) is still written for 1.x/Node.js readers and read only when `_priority` is absent (`Task` serializes through `TaskRepr` in `models/task.rs`). 4. **Atomic Writes**: Local storage operations write to a temp file first, then rename to prevent data corruption on crash. @@ -117,7 +120,11 @@ crates/ (honoring `$XDG_CONFIG_HOME`), falling back to the legacy `~/.taskbook.json` when it exists and the XDG file does not. -6. **Storage Structure (Local)**: +6. **One Input Grammar**: Item ids are bare numbers everywhere (CLI, TUI command line); `@name` always names a board and `@3` is rejected with a hint (`taskbook_common::id`). Item-creation tokens (`@board`, `+tag`, `p:N`, `due:DATE`) are parsed by `board::parse_item_input` for both the CLI and the TUI. + +7. **Errors**: `Taskbook` methods never print. They validate every id (existence, and task-ness for task-only operations) before mutating, and return a typed `TaskbookError` whose `Display` is the full user-facing message. `main` prints it once and exits 1; clap usage errors exit 2. The TUI shows errors in the status bar. + +8. **Storage Structure (Local)**: ``` ~/.taskbook/ ├── storage/storage.json # Active items @@ -128,48 +135,54 @@ crates/ ## CLI Usage +Ids are bare numbers; `@name` always names a board. Priority: 1 = high, +2 = medium, 3 = normal (default). The 1.x flag form (`tb --task ...`) is +rewritten to the subcommand by `cli::rewrite_legacy_args` with a +deprecation warning (removal planned for 3.0). + ```bash # Task management -tb # Display board view (TUI) -tb --task "Description" # Create task -tb --task @board "Desc" # Create task in specific board -tb --task "Desc" p:2 # Create with priority (1=normal, 2=medium, 3=high) -tb --task "Desc" due:2026-07-01 # Create with due date (also due:today, due:tomorrow) -tb --task "Desc" due:tomorrow+0930 # Due with time via token: today/tomorrow/now+HHMM or YYYY-MM-DDTHH:MM (no spaces) -tb --note "Description" # Create note -tb --note # Create note in external editor ($EDITOR) -tb --check [id...] # Toggle task complete -tb --begin [id...] # Toggle task in-progress -tb --star [id...] # Toggle starred -tb --delete [id...] # Delete to archive -tb --restore [id...] # Restore from archive -tb --edit @ "New desc" # Edit description -tb --edit-note @ # Edit note in external editor ($EDITOR) -tb --move @ board # Move to board -tb --priority @ <1-3> # Set priority -tb --due @ # Set due date (YYYY-MM-DD[ HH:MM], today, tomorrow, now, *+HHMM) or none to clear -tb --find # Search items -tb --list # Filter (pending, done, task, note, starred) -tb --timeline # Chronological view -tb --archive # View archived items -tb --clear # Delete all completed tasks -tb --copy [id...] # Copy descriptions to clipboard +tb # Interactive board (TUI) +tb board # Print board view (non-interactive) +tb task Description # Create task +tb task @board Desc # Create task in specific board +tb task Desc p:1 # Create with priority (1=high, 2=medium, 3=normal) +tb task Desc due:2026-07-01 # Create with due date (also due:today, due:tomorrow) +tb task Desc due:tomorrow+0930 # Due with time via token: today/tomorrow/now+HHMM or YYYY-MM-DDTHH:MM (no spaces) +tb note Description # Create note +tb note # Create note in external editor ($EDITOR) +tb check ... # Toggle task complete +tb begin ... # Toggle task in-progress +tb star ... # Toggle starred +tb delete ... # Delete to archive +tb restore ... # Restore from archive +tb edit New desc # Edit description +tb edit-note # Edit note in external editor ($EDITOR) +tb move ... @board... # Put items on boards +tb priority ... # Set priority (1|2|3, p1, p:1, high|medium|normal) +tb due ... # Set due date (YYYY-MM-DD[ HH:MM], today, tomorrow, now, *+HHMM) or none to clear +tb tag ... +a -b # Add/remove tags +tb find ... # Search items +tb list ... # Filter (pending, progress, done, task, note, starred, +tag, @board) +tb timeline # Chronological view +tb archive # View archived items +tb clear # Archive all completed tasks +tb copy ... # Copy descriptions to clipboard # Server sync -tb --register --server --username --email --password -tb --login --server --username --password --key -tb --logout -tb --status -tb --migrate # Push local data to server +tb register --server --username --email --password +tb login --server --username --password --key +tb logout +tb status +tb migrate # Push local data to server # Mode -tb --cli # Force non-interactive CLI mode -tb --mcp # Run as MCP stdio server +tb mcp # Run as MCP stdio server ``` ## MCP Server -`tb --mcp` runs a Model Context Protocol server over stdio (newline-delimited +`tb mcp` runs a Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0, no extra dependencies). It uses the configured storage backend: the remote server storage (with client-side encryption) when `sync.enabled = true` in the config file (`~/.config/taskbook/taskbook.json`), local files @@ -178,13 +191,14 @@ otherwise. Register it in Claude Code: ```bash -claude mcp add taskbook -- tb --mcp +claude mcp add taskbook -- tb mcp ``` Exposed tools: `list_items` (filters: state/type, board, tag, text query, archived), `list_boards`, `create_task` (with priority, tags, due date), `create_note`, `set_task_state` (pending/in_progress/done — deterministic, not -a toggle), `edit_item` (partial update incl. due date and starred), +a toggle), `edit_item` (partial update incl. due date and starred; validated +up front and applied in one write), `priority` is 1 = high … 3 = normal, `delete_items`, `restore_items`. Implementation: `crates/taskbook-client/src/mcp/`. ## Server Configuration diff --git a/README.md b/README.md index d9b00e4..fa08735 100644 --- a/README.md +++ b/README.md @@ -76,48 +76,56 @@ Browse and restore deleted items. ## Usage ```bash -tb # Display board view -tb --task "Description" # Create task -tb --task @board "Desc" # Create task in specific board -tb --task "Desc" p:2 # Create with priority (1=normal, 2=medium, 3=high) -tb --note "Description" # Create note -tb --note # Create note in external editor -tb --edit-note @ # Edit note in external editor -tb --check [id...] # Toggle task complete -tb --begin [id...] # Toggle task in-progress -tb --star [id...] # Toggle starred -tb --delete [id...] # Delete to archive -tb --restore [id...] # Restore from archive -tb --edit @ "New desc" # Edit description -tb --move @ board # Move to board -tb --priority @ <1-3> # Set priority -tb --find # Search items -tb --list # Filter (pending, done, task, note, starred) -tb --timeline # Chronological view -tb --archive # View archived items -tb --clear # Delete all completed tasks -tb --copy [id...] # Copy descriptions to clipboard +tb # Interactive board (TUI) +tb board # Print the board view +tb task Description # Create task +tb task @board Desc # Create task in a specific board +tb task Desc p:1 # Create with priority (1=high, 2=medium, 3=normal) +tb task Desc +tag due:tomorrow # Create with a tag and a due date +tb note Description # Create note +tb note # Create note in external editor +tb edit-note # Edit note in external editor +tb check ... # Toggle task complete +tb begin ... # Toggle task in-progress +tb star ... # Toggle starred +tb delete ... # Delete to archive +tb restore ... # Restore from archive +tb edit New desc # Edit description +tb move ... @board # Move to board +tb priority ... <1-3> # Set priority +tb due ... # Set or clear due date +tb tag ... +add -remove # Add/remove tags +tb find ... # Search items +tb list ... # Filter (pending, done, task, note, starred, +tag, @board) +tb timeline # Chronological view +tb archive # View archived items +tb clear # Archive all completed tasks +tb copy ... # Copy descriptions to clipboard ``` +Ids are bare numbers; `@name` always names a board. Coming from 1.x (or the +Node.js taskbook)? See [Upgrading to 2.0](docs/upgrading-to-2.0.md). The 1.x +flags (`tb --task ...`) still work with a deprecation warning. + ## Server Sync Optionally sync your tasks across devices with encrypted server storage: ```bash # Register an account (interactive) -tb --register +tb register # Migrate existing local data to server -tb --migrate +tb migrate # On another device, login with your encryption key -tb --login +tb login # Check sync status -tb --status +tb status # Logout and return to local-only mode -tb --logout +tb logout ``` All data is encrypted client-side with AES-256-GCM before being sent to the server. Your encryption key is generated locally during registration and never leaves your device. diff --git a/docs/README.md b/docs/README.md index ea11408..d20220c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,19 +8,19 @@ Taskbook is a CLI application for managing tasks and notes organized into boards ```bash # Create a task -tb --task "Review pull request" +tb task "Review pull request" # Create a task in a specific board with priority -tb --task @work "Deploy to production" p:3 +tb task @work "Deploy to production" p:1 # View your boards tb # Mark task as complete -tb --check 1 +tb check 1 # Start working on a task -tb --begin 2 +tb begin 2 ``` ## Documentation @@ -29,6 +29,7 @@ tb --begin 2 |----------|-------------| | [Installation](installation.md) | How to install the client and server | | [CLI Reference](cli-reference.md) | Complete command reference | +| [Upgrading to 2.0](upgrading-to-2.0.md) | Breaking changes from 1.x and the Node.js taskbook | | [Configuration](configuration.md) | Client configuration options | | [Server Setup](server.md) | Running the sync server | | [Sync & Encryption](sync.md) | Setting up sync between devices | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 77167f6..5d6522b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3,424 +3,428 @@ ## Synopsis ``` -tb [OPTIONS] [INPUT...] +tb [--taskbook-dir PATH] [COMMAND] [ARGS...] ``` +Running `tb` with no command opens the interactive board (TUI). Every other +action is a subcommand: `tb task ...`, `tb check 1`, and so on. Run +`tb --help` for the arguments of one command. + +Two rules hold across every command: + +- **Item ids are bare numbers** (`3`). Commands that act on items accept one + or more leading ids: `tb check 1 2`, `tb priority 3 4 p1`. +- **`@name` always names a board.** `@3` is never an id; taskbook 1.x used + that form for some commands, and 2.0 rejects it with a hint. + +Upgrading from 1.x? See [Upgrading to 2.0](upgrading-to-2.0.md). + ## Display Commands -### Board View (default) +### Board View ```bash -tb +tb board ``` -Displays all items organized by board. This is the default when no options are provided. +Prints all items grouped by board, followed by overall progress. Use this +instead of `tb` when you want non-interactive output (scripts, pipes). ### Timeline View ```bash -tb --timeline -tb -i +tb timeline ``` -Displays items in chronological order by creation date. +Prints items grouped by creation date. ### Archive View ```bash -tb --archive -tb -a +tb archive ``` -Displays archived (deleted) items. +Prints archived (deleted) items. The ids shown here are archive ids, used by +`tb restore`. ## Creating Items ### Create Task ```bash -tb --task -tb -t +tb task [@BOARD]... [+TAG]... [p:1-3] [due:DATE] ... ``` -Creates a new task. Tasks can be checked off when completed. +Tokens may appear in any order; everything that is not a board, tag, +priority or due date is the description. -**Board assignment**: Prefix board names with `@`: +**Boards**: prefix board names with `@`. Without one, the task goes to +`My Board`. ```bash -tb --task @work "Review pull request" -tb --task @coding @review "Implement feature X" # Multiple boards +tb task @work Review pull request +tb task @coding @review Implement feature X # Multiple boards ``` -**Priority**: Append `p:N` where N is 1 (normal), 2 (medium), or 3 (high): +**Priority**: `p:1` = high, `p:2` = medium, `p:3` = normal (the default). +`p:high`, `p:medium` and `p:normal` also work. Any other `p:` value is an +error. ```bash -tb --task "Fix critical bug" p:3 # High priority -tb --task @work "Update docs" p:2 # Medium priority +tb task Fix critical bug p:1 # High priority +tb task @work Update docs p:2 # Medium priority ``` -**Combined example**: +**Tags**: prefix tags with `+`. ```bash -tb --task @work @urgent "Deploy hotfix" p:3 +tb task @coding +urgent +frontend Fix login bug +``` + +**Due date**: `due:` followed by `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, `today`, +`tomorrow`, `now`, or `today+HHMM` / `tomorrow+HHMM` / `now+HHMM`. A token +cannot contain a space, so use the `T` separator for a time. + +```bash +tb task Pay rent due:2026-07-01 +tb task Call dentist due:tomorrow+0930 ``` ### Create Note ```bash -tb --note -tb -n -tb --note +tb note [@BOARD]... [+TAG]... [TEXT]... +tb note ``` -Creates a note. Notes are for reference and cannot be checked off. +Creates a note. Notes cannot be checked off and have no priority or due date +(`p:` and `due:` tokens are rejected). -When called without a description, opens your external editor (`$EDITOR` or `$VISUAL`, falling back to `vi`) to compose a note with a title and optional body. Lines starting with `//` are treated as comments and ignored. Delete all content to cancel. +With no text, opens your external editor (`$EDITOR` or `$VISUAL`, falling +back to `vi`) to compose a title and optional body. Lines starting with `//` +are comments. Delete all content to cancel. ```bash -tb --note "API endpoint: https://api.example.com" -tb --note @meetings "Standup at 9am daily" -tb --note # Opens editor for multi-line note +tb note API endpoint: https://api.example.com +tb note @meetings Standup at 9am daily +tb note # Opens editor for a multi-line note ``` ## Modifying Items -### Check/Uncheck Task +Every command below validates all of its ids before changing anything: if +one id does not exist (or a task-only command is given a note), nothing is +changed and every offending id is reported. + +### Check / Uncheck Task ```bash -tb --check [id...] -tb -c [id...] +tb check ... ``` -Toggles the completion status of tasks. Multiple IDs can be specified. +Toggles tasks between done and pending. + +### Begin / Pause Task ```bash -tb --check 1 -tb --check 1 2 3 +tb begin ... ``` -### Begin/Pause Task +Toggles tasks between in-progress and pending. + +### Star / Unstar Item ```bash -tb --begin [id...] -tb -b [id...] +tb star ... ``` -Toggles the in-progress status of tasks. In-progress tasks are highlighted differently in the board view. +Toggles the star on tasks and notes. + +### Edit Description ```bash -tb --begin 2 -tb --begin 4 5 +tb edit ... ``` -### Star/Unstar Item +Replaces one item's description. Everything after the id is the new text, +including words that start with `@` or `+`. ```bash -tb --star [id...] -tb -s [id...] +tb edit 3 Merge PR #42 ``` -Toggles the starred status. Works on both tasks and notes. +### Edit Note in Editor ```bash -tb --star 1 -tb --star 1 3 5 +tb edit-note ``` -### Edit Description +Opens a note in your external editor to edit its title and body. The first +non-comment line becomes the title; the remaining lines become the body. + +### Set Priority ```bash -tb --edit @ -tb -e @ +tb priority ... ``` -Edits an item's description. The ID must be prefixed with `@`. +The last argument is the level: `1`/`high`, `2`/`medium`, `3`/`normal` +(`p1` and `p:1` also work). Tasks only. ```bash -tb --edit @3 "Updated task description" +tb priority 5 1 # High +tb priority 5 6 medium # Medium, two tasks ``` -### Edit Note in Editor +### Set or Clear Due Date ```bash -tb --edit-note @ +tb due ... ``` -Opens a note in your external editor (`$EDITOR` or `$VISUAL`, falling back to `vi`) for editing both the title and body. The first non-comment line becomes the title, and remaining lines become the body. Lines starting with `//` are comments and ignored. Delete all content to cancel. +Accepts the `due:` formats above, plus `"YYYY-MM-DD HH:MM"` (quoted or not). +`none` clears the due date. Tasks only. ```bash -tb --edit-note @5 +tb due 3 2026-07-01 +tb due 3 2026-07-01 14:30 +tb due 3 4 tomorrow +tb due 3 none ``` -### Set Priority +### Move to Boards ```bash -tb --priority @ <1-3> -tb -p @ <1-3> +tb move ... <@BOARD>... ``` -Sets task priority: 1 = normal, 2 = medium, 3 = high. +Puts items on exactly the given boards, replacing their current boards. The +`@` is optional here, but a board whose name is a number must be written +with it (`@2026`), since bare numbers are ids. ```bash -tb --priority @5 3 # Set to high priority -tb --priority @5 1 # Set to normal priority +tb move 3 @work +tb move 3 4 @work @review ``` -### Move to Board +### Add / Remove Tags ```bash -tb --move @ -tb -m @ +tb tag ... <+TAG|-TAG>... ``` -Moves an item to a different board. Opens a board picker if the board name is omitted. - ```bash -tb --move @3 work -tb --move @3 @personal # @ prefix is optional for board name +tb tag 3 +urgent +frontend +tb tag 3 4 -urgent ``` ## Deleting and Restoring -### Delete Item +### Delete Items ```bash -tb --delete [id...] -tb -d [id...] +tb delete ... ``` -Moves items to the archive. They can be restored later. - -```bash -tb --delete 4 -tb --delete 1 2 3 -``` +Moves items to the archive. Archived items get new (archive) ids. ### Restore from Archive ```bash -tb --restore [id...] -tb -r [id...] +tb restore ... ``` -Restores archived items back to the active board. - -```bash -tb --restore 4 -tb --restore 10 11 12 -``` +Moves archived items back, using the ids shown by `tb archive`. Restored +items get new ids. ### Clear Completed ```bash -tb --clear +tb clear ``` -Permanently deletes all completed (checked) tasks from all boards. +Moves every completed task to the archive. ## Searching and Filtering ### Find Items ```bash -tb --find -tb -f +tb find ... ``` -Searches for items containing the search term in their description. +Shows items where any term matches the description, note body or tags +(case-insensitive substring). A `+term` only matches tags. ```bash -tb --find documentation -tb --find "pull request" +tb find documentation +tb find "pull request" +tb find +urgent ``` -### List by Attributes +### List by Filters ```bash -tb --list -tb -l +tb list [FILTER]... ``` -Filters items by one or more attributes: +Shows items matching every filter. With no filters, same as `tb board`. + +| Filter | Matches | +|--------|---------| +| `pending` | Tasks neither done nor in progress | +| `progress` | In-progress tasks (also `started`, `begun`, `in_progress`) | +| `done` | Completed tasks (also `checked`, `complete`) | +| `task` | All tasks (also `tasks`, `todo`) | +| `note` | All notes (also `notes`) | +| `starred` | Starred items (also `star`) | +| `+tag` | Items with that tag | +| `@board` | Restricts the view to that board (must exist) | -| Attribute | Description | -|-----------|-------------| -| `pending` | Incomplete tasks | -| `done` | Completed tasks | -| `task` | All tasks | -| `note` | All notes | -| `starred` | Starred items | +A bare word that is not a filter is accepted if it names an existing board; +anything else is an error (so a typo like `pendng` fails instead of showing +everything). ```bash -tb --list pending -tb --list done starred -tb --list task pending # Pending tasks only +tb list pending +tb list done starred +tb list pending @coding +urgent ``` ## Clipboard -### Copy to Clipboard - ```bash -tb --copy [id...] -tb -y [id...] +tb copy ... ``` -Copies item descriptions to the system clipboard. Multiple items are joined with newlines. - -```bash -tb --copy 1 -tb --copy 1 2 3 -``` +Copies item descriptions to the system clipboard, one per line. ## Server Commands -These commands are used for syncing with a remote server. See [Sync & Encryption](sync.md) for details. +These commands sync with a remote server. See [Sync & Encryption](sync.md) +for details. Each prompts interactively for values not given as options. ### Register Account ```bash -tb --register [OPTIONS] +tb register [--server URL] [--username NAME] [--email EMAIL] [--password PASS] ``` -Creates a new account on the sync server. Prompts interactively for missing values. - -| Option | Description | -|--------|-------------| -| `--server ` | Server URL | -| `--username ` | Username | -| `--email ` | Email address | -| `--password ` | Password (prompted securely if omitted) | - ```bash -# Fully interactive (recommended) -tb --register - -# Partial - prompts for password -tb --register --server https://taskbook.example.com --username alice --email alice@example.com +tb register # Fully interactive (recommended) +tb register --server https://taskbook.example.com --username alice --email alice@example.com ``` ### Login ```bash -tb --login [OPTIONS] +tb login [--server URL] [--username NAME] [--password PASS] [--key BASE64] ``` -Logs in to an existing account. Prompts interactively for missing values. - -| Option | Description | -|--------|-------------| -| `--server ` | Server URL | -| `--username ` | Username | -| `--password ` | Password (prompted securely if omitted) | -| `--key ` | Encryption key from registration | - -```bash -# Fully interactive -tb --login - -# With server specified -tb --login --server https://taskbook.example.com -``` +`--key` is the encryption key printed at registration. ### Logout ```bash -tb --logout +tb logout ``` -Logs out and deletes stored credentials. Sync is disabled and the client returns to local-only mode. +Logs out and deletes stored credentials. The client returns to local-only +mode. ### Status ```bash -tb --status +tb status ``` -Shows current sync status, server URL, and whether credentials are saved. +Shows sync status, server URL, and whether credentials are saved. ### Migrate Local Data ```bash -tb --migrate +tb migrate ``` -Pushes existing local data to the server. Use this after registering to upload your existing tasks. - -## Global Options +Pushes existing local data to the server. Use this after registering to +upload your existing items. -### Custom Taskbook Directory +### MCP Server ```bash -tb --taskbook-dir +tb mcp ``` -Use a custom directory for storing taskbook data instead of `~/.taskbook/`. +Runs a Model Context Protocol server over stdio (newline-delimited JSON-RPC +2.0) against the configured storage backend. Register it with Claude Code: ```bash -tb --taskbook-dir ~/work-tasks --task "Work item" +claude mcp add taskbook -- tb mcp ``` -Can also be set via environment variable: +Tools: `list_items`, `list_boards`, `create_task`, `create_note`, +`set_task_state`, `edit_item`, `delete_items`, `restore_items`. The +`priority` field uses the same scale as the CLI (1 = high … 3 = normal). -```bash -export TASKBOOK_DIR=~/work-tasks -tb --task "Work item" -``` +## Global Options -### CLI Mode +### Custom Taskbook Directory ```bash -tb --cli +tb --taskbook-dir [COMMAND] ``` -Runs in non-interactive CLI mode, printing output to stdout instead of launching the interactive TUI. Useful for scripting or piping output. - -### Help +Uses a custom directory instead of `~/.taskbook/`. Can also be set with the +`TASKBOOK_DIR` environment variable. ```bash -tb --help -tb -h +tb --taskbook-dir ~/work-tasks task Work item +TASKBOOK_DIR=~/work-tasks tb board ``` -Displays help message with all available options. - -### Version +### Help and Version ```bash -tb --version -tb -v +tb --help # All commands +tb --help # One command +tb --version # Also -v ``` -Displays the installed version. +## Exit Status and Errors + +`tb` exits `0` on success, `1` when an operation fails (unknown id, note +given a priority, invalid date, ...), and `2` for a malformed command line +(unknown command, non-numeric id, missing argument). Each error is printed +once, to stderr. ## Examples ```bash # Daily workflow -tb # View boards -tb --task @work "Code review" # Create task -tb --begin 1 # Start working -tb --check 1 # Mark done +tb # Interactive board +tb task @work Code review p:1 # Create a high-priority task +tb begin 1 # Start working +tb check 1 # Mark done -# Organize tasks -tb --task @personal "Buy groceries" p:2 -tb --star 3 -tb --move @3 shopping +# Organize +tb task @personal Buy groceries p:2 due:tomorrow +tb star 3 +tb move 3 @shopping +tb tag 3 +errands # Search and filter -tb --find meeting -tb --list pending starred -tb --timeline +tb find meeting +tb list pending starred +tb timeline # Cleanup -tb --delete 5 6 7 -tb --clear -tb --archive # View deleted items -tb --restore 5 # Oops, restore one +tb delete 5 6 7 +tb clear +tb archive # View deleted items +tb restore 5 # Restore by archive id # Server sync -tb --register -tb --migrate -tb --status +tb register +tb migrate +tb status ``` diff --git a/docs/configuration.md b/docs/configuration.md index 119fdc5..4c014ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -60,7 +60,7 @@ Whether to show completed tasks in the board view. } ``` -When `false`, completed tasks are hidden from the board view but still exist and can be seen with `tb --list done`. +When `false`, completed tasks are hidden from the board view but still exist and can be seen with `tb list done`. ### displayProgressOverview diff --git a/docs/installation.md b/docs/installation.md index e26b740..949205e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -109,7 +109,7 @@ docker compose up -d tb --version # Create your first task -tb --task "Hello, taskbook!" +tb task "Hello, taskbook!" # View your board tb diff --git a/docs/sync.md b/docs/sync.md index 7d525e7..a4c84fc 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -25,7 +25,7 @@ Taskbook supports syncing your tasks across multiple devices using an encrypted ### 1. Register an Account ```bash -tb --register +tb register ``` You'll be prompted for: @@ -52,7 +52,7 @@ Your encryption key (save this — it cannot be recovered): After registration, sync is automatically enabled. Use taskbook normally: ```bash -tb --task "My synced task" +tb task "My synced task" tb ``` @@ -63,7 +63,7 @@ All operations are now synced to the server. To access your tasks on another device: ```bash -tb --login +tb login ``` You'll be prompted for: @@ -80,12 +80,12 @@ If you have existing local tasks and want to sync them to the server: ```bash # First, register or login -tb --register +tb register # or -tb --login +tb login # Then migrate your local data -tb --migrate +tb migrate ``` The migrate command: @@ -96,7 +96,7 @@ The migrate command: ## Check Sync Status ```bash -tb --status +tb status ``` Output shows: @@ -112,7 +112,7 @@ Server URL: https://taskbook.example.com To disable sync and remove credentials: ```bash -tb --logout +tb logout ``` This: @@ -212,7 +212,7 @@ curl http://localhost:8080/api/v1/health Your session may have expired: ```bash # Login again -tb --login +tb login ``` ### "Decryption failed" @@ -226,7 +226,7 @@ cat ~/.taskbook/credentials.json ### Lost Encryption Key If you've lost your encryption key, your data cannot be recovered. You'll need to: -1. Create a new account with `tb --register` +1. Create a new account with `tb register` 2. Start fresh with new tasks This is by design - the server cannot decrypt your data without your key. diff --git a/docs/upgrading-to-2.0.md b/docs/upgrading-to-2.0.md new file mode 100644 index 0000000..353e5b1 --- /dev/null +++ b/docs/upgrading-to-2.0.md @@ -0,0 +1,100 @@ +# Upgrading to 2.0 + +Taskbook 2.0 drops several behaviors inherited from the original Node.js +taskbook. Everything below is a breaking change to the command line, the TUI +command line, or the MCP tools. Your data does not need a manual migration. + +## Commands are subcommands + +Actions are subcommands instead of flags. + +| 1.x | 2.0 | +|-----|-----| +| `tb --task @work Fix it` | `tb task @work Fix it` | +| `tb --check 1 2` | `tb check 1 2` | +| `tb --cli` | `tb board` | +| `tb --register --server URL` | `tb register --server URL` | +| `tb --mcp` | `tb mcp` | + +The 1.x flags (`--task`/`-t`, `--check`/`-c`, ...) still work in 2.x: they +are rewritten to the matching subcommand and print a deprecation warning on +stderr. They will be removed in 3.0. Update scripts and your MCP +registration: + +```bash +claude mcp remove taskbook +claude mcp add taskbook -- tb mcp +``` + +`tb ` with no command used to print the board and ignore the words; +it is now an "unrecognized subcommand" error. + +## Ids never take `@` + +In 1.x, `@` meant an item id for `--edit`, `--move`, `--priority`, `--due`, +`--tag` and `--edit-note`, but a board everywhere else. In 2.0 ids are always +bare numbers and `@` always names a board. + +| 1.x | 2.0 | +|-----|-----| +| `tb --edit @3 New text` | `tb edit 3 New text` | +| `tb --move @3 work` | `tb move 3 @work` | +| `tb --priority @3 3` | `tb priority 3 1` (see below) | +| `tb --due @3 tomorrow` | `tb due 3 tomorrow` | +| `tb --tag @3 +x` | `tb tag 3 +x` | + +`tb edit @3 ...` is rejected with a hint (`use 3`), so old muscle memory +fails loudly instead of doing something else. The same applies to the TUI +command line (`/edit 3 ...`, `/move 3 @work`, `/priority 3 1`). + +`move`, `priority`, `due` and `tag` now accept several ids: +`tb priority 3 4 5 high`. + +## Priority 1 is the highest + +| Meaning | 1.x | 2.0 | +|---------|-----|-----| +| High (`!!`) | 3 | **1** | +| Medium (`!`) | 2 | 2 | +| Normal (default) | 1 | **3** | + +This applies to `p:N` when creating a task, to `tb priority`, to the TUI, +and to the MCP `priority` field (input and output). Labels work too: +`p:high`, `tb priority 3 medium`. + +**Stored data.** 2.0 writes the new scale to a new `_priority` field and +also keeps writing the old `priority` field with the 1.x value, so: + +- Existing 1.x data (and data from the Node.js taskbook) is read correctly; + nothing to migrate. +- A 1.x client sharing the same directory or sync server keeps reading + correct priorities from items 2.0 wrote. When 1.x saves an item it drops + `_priority`, and 2.0 falls back to the legacy field. + +## Stricter input + +Input that 1.x silently ignored is now an error, and nothing is changed: + +- A non-numeric id: `tb check 1 foo` used to check 1 and ignore `foo`. +- A missing id: the whole command fails and lists every missing id, rather + than stopping at the first one. +- A task-only command on a note: `check` and `begin` on a note used to do + nothing, and `priority` reported success without changing anything. +- An invalid `p:` token: `tb task Fix p:4` used to create a task described as + "Fix p:4". +- `p:` or `due:` on a note: previously dropped without a word. +- An unknown `tb list` filter: `tb list pendng` used to show everything. An + `@board` that does not exist is also an error. +- A `tb tag` argument that is neither `+tag` nor `-tag`. +- MCP: an `ids` array containing a non-integer, and an `edit_item` call that + mixes valid and invalid fields. `edit_item` now validates every field + before applying any of them, and applies them in one write. + +## Error output + +Each error is printed once, to stderr (1.x printed a specific message and +then a generic `Invalid item ID: 0`). Exit status is `1` for a failed +operation and `2` for a malformed command line. + +In the TUI, a failing slash command (`/delete 99`, `/edit 99 x`) shows the +error in the status bar. Previously it closed the TUI. From 6f32fa9baedc5d34ba9cf2785cbab37d16621132 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:27:04 +0000 Subject: [PATCH 3/4] Add `tb show` and TUI `/show` to look up items by id `tb show ... [--archived]` prints every field of the given items, including note bodies. In the TUI, `/show ...` narrows the current view to those ids (Esc clears; switching views clears it since archive ids differ). The three copies of the TUI visibility filter (board/timeline, journal ordering, journal widget) now all go through App::should_show_item; the journal widget's copy only searched descriptions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014rB6uhLWQEZyDE3XSNsyTZ --- CLAUDE.md | 1 + README.md | 1 + crates/taskbook-client/src/cli.rs | 19 +++++++ crates/taskbook-client/src/commands.rs | 6 ++ crates/taskbook-client/src/render.rs | 57 +++++++++++++++++++ crates/taskbook-client/src/taskbook.rs | 26 +++++++++ crates/taskbook-client/src/tui/actions.rs | 22 ++++++- crates/taskbook-client/src/tui/app.rs | 33 +++-------- .../taskbook-client/src/tui/autocomplete.rs | 3 +- .../taskbook-client/src/tui/command_parser.rs | 14 +++++ .../src/tui/widgets/help_popup.rs | 4 ++ .../src/tui/widgets/journal_view.rs | 17 +----- .../src/tui/widgets/status_bar.rs | 17 ++++++ docs/cli-reference.md | 19 +++++++ 14 files changed, 196 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f7eda6..585d9f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ deprecation warning (removal planned for 3.0). # Task management tb # Interactive board (TUI) tb board # Print board view (non-interactive) +tb show ... [--archived] # Print every field of items (TUI: /show ... filters the view) tb task Description # Create task tb task @board Desc # Create task in specific board tb task Desc p:1 # Create with priority (1=high, 2=medium, 3=normal) diff --git a/README.md b/README.md index fa08735..08b1c09 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Browse and restore deleted items. ```bash tb # Interactive board (TUI) tb board # Print the board view +tb show ... # Print every field of items (incl. note bodies) tb task Description # Create task tb task @board Desc # Create task in a specific board tb task Desc p:1 # Create with priority (1=high, 2=medium, 3=normal) diff --git a/crates/taskbook-client/src/cli.rs b/crates/taskbook-client/src/cli.rs index 696e0a9..7089d18 100644 --- a/crates/taskbook-client/src/cli.rs +++ b/crates/taskbook-client/src/cli.rs @@ -15,6 +15,7 @@ Running tb with no command opens the interactive board. Examples: tb task @coding +urgent Review PR #42 p:1 due:tomorrow+0930 tb note @coding Mergesort worst case is O(n log n) + tb show 3 tb check 1 2 tb priority 3 4 p1 tb move 3 @reviews @@ -57,6 +58,15 @@ pub enum Command { /// Show archived (deleted) items Archive, + /// Show every field of the given items, including note bodies + Show { + #[arg(required = true, value_parser = parse_id_arg, value_name = "ID")] + ids: Vec, + /// Look up archive ids (as shown by `tb archive`) + #[arg(long)] + archived: bool, + }, + /// Create a task #[command( override_usage = "tb task [@BOARD]... [+TAG]... [p:1-3] [due:DATE] ..." @@ -429,6 +439,15 @@ mod tests { ); } + #[test] + fn test_show_parses_ids_and_archive_flag() { + let cli = parse(&["tb", "show", "3", "4", "--archived"]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Show { ref ids, archived: true }) if ids == &[3, 4] + )); + } + #[test] fn test_bare_words_are_not_a_command() { assert!(parse(&["tb", "buy", "milk"]).is_err()); diff --git a/crates/taskbook-client/src/commands.rs b/crates/taskbook-client/src/commands.rs index 9b2e465..a654471 100644 --- a/crates/taskbook-client/src/commands.rs +++ b/crates/taskbook-client/src/commands.rs @@ -72,6 +72,12 @@ fn run_item_command(tb: &Taskbook, render: &Render, command: Command) -> Result< render.display_stats(&Stats::from_items(data.values())); } Command::Archive => render.display_by_date(&group_by_date(&tb.archived_items()?)), + Command::Show { ids, archived } => { + for item in tb.get(&ids, archived)? { + render.display_details(&item); + } + println!(); + } Command::Task { input } => { let parsed = board::parse_cli_input(&input)?; let id = tb.create_task(NewTask { diff --git a/crates/taskbook-client/src/render.rs b/crates/taskbook-client/src/render.rs index 514cf55..3f5dd13 100644 --- a/crates/taskbook-client/src/render.rs +++ b/crates/taskbook-client/src/render.rs @@ -371,6 +371,63 @@ impl Render { } } + /// Print every field of one item (`tb show`). + pub fn display_details(&self, item: &StorageItem) { + println!( + "\n{} {} {}", + self.build_prefix(item), + self.get_item_icon(item), + self.build_message(item) + ); + + let row = |label: &str, value: String| { + println!(" {} {}", self.muted(&format!("{label:<9}")), value); + }; + + match item.as_task() { + Some(task) => { + let state = if task.is_complete { + "done" + } else if task.in_progress { + "in progress" + } else { + "pending" + }; + row("type", format!("task · {state}")); + row("priority", task.priority.to_string()); + if let Some(millis) = task.due_date { + let status = match due::due_status(millis) { + due::DueStatus::Overdue => " (overdue)", + due::DueStatus::DueToday => " (today)", + due::DueStatus::Upcoming => "", + }; + row("due", format!("{}{status}", due::format_due_date(millis))); + } + } + None => row("type", "note".to_string()), + } + let boards: Vec = item + .boards() + .iter() + .map(|b| board::display_name(b)) + .collect(); + row("boards", boards.join(", ")); + if !item.tags().is_empty() { + row("tags", self.color_tags(item.tags())); + } + if item.is_starred() { + row("starred", self.starred("★").to_string()); + } + row("created", item.date().to_string()); + + if let Some(body) = item.note_body() { + println!(); + for line in body.lines() { + println!(" {line}"); + } + } + } + pub fn display_stats(&self, stats: &Stats) { if !self.config.display_progress_overview { return; diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 608f089..5d5b465 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -223,6 +223,18 @@ impl Taskbook { self.storage.get_archive() } + /// The items with these ids (deduped, in the given order), from the + /// archive when `archived` is set. + pub fn get(&self, ids: &[u64], archived: bool) -> Result> { + let data = if archived { + self.archived_items()? + } else { + self.items()? + }; + let ids = resolve_ids(&data, ids)?; + Ok(ids.iter().map(|id| data[&id.to_string()].clone()).collect()) + } + pub fn boards(&self) -> Result> { Ok(Self::boards_of(&self.items()?)) } @@ -710,6 +722,20 @@ mod tests { assert!(tb.clear_completed().unwrap().is_empty()); } + #[test] + fn test_get_returns_requested_items_in_order() { + let tb = taskbook(); + let a = task(&tb, "a"); + let b = note(&tb, "b"); + let items = tb.get(&[b, a, b], false).unwrap(); + let ids: Vec = items.iter().map(StorageItem::id).collect(); + assert_eq!(ids, vec![b, a]); + assert!(matches!( + tb.get(&[a], true).unwrap_err(), + TaskbookError::UnknownIds(_) + )); + } + #[test] fn test_note_content_requires_note() { let tb = taskbook(); diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 162fb5b..599850c 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -273,6 +273,21 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { StatusKind::Info, ); } + ParsedCommand::Show { ids } => { + let unknown: Vec = ids + .iter() + .copied() + .filter(|id| !app.items.contains_key(&id.to_string())) + .collect(); + if !unknown.is_empty() { + return Err(crate::error::TaskbookError::UnknownIds(unknown)); + } + app.clear_board_filter(); + app.filter.search_term = None; + app.filter.id_filter = Some(ids); + app.update_display_order(); + app.selected_index = 0; + } ParsedCommand::Priority { id, level } => { set_priority(app, id, level)?; } @@ -352,7 +367,12 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Quit KeyCode::Char('q') => app.quit(), KeyCode::Esc => { - if app.filter.search_term.is_some() { + if app.filter.id_filter.is_some() { + app.filter.id_filter = None; + app.update_display_order(); + app.selected_index = 0; + app.set_status("Showing all items".to_string(), StatusKind::Info); + } else if app.filter.search_term.is_some() { app.filter.search_term = None; app.update_display_order(); app.selected_index = 0; diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 67d0814..48a92de 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -157,6 +157,8 @@ pub struct FilterState { #[allow(dead_code)] pub attributes: Vec, pub search_term: Option, + /// Show only these item ids (`/show`). Overrides the other filters. + pub id_filter: Option>, /// Filter to show only items from this board pub board_filter: Option, /// Hide completed tasks @@ -246,6 +248,9 @@ impl App { /// Check if an item should be shown based on current filters pub fn should_show_item(&self, item: &StorageItem) -> bool { + if let Some(ref ids) = self.filter.id_filter { + return ids.contains(&item.id()); + } if self.filter.hide_completed { if let Some(task) = item.as_task() { if task.is_complete { @@ -324,31 +329,7 @@ impl App { let mut items: Vec<_> = self .items .values() - .filter(|item| { - if self.filter.hide_completed { - if let Some(task) = item.as_task() { - if task.is_complete { - return false; - } - } - } - if let Some(ref term) = self.filter.search_term { - let term_lower = term.to_lowercase(); - let in_desc = item.description().to_lowercase().contains(&term_lower); - let in_body = item - .note_body() - .map(|b| b.to_lowercase().contains(&term_lower)) - .unwrap_or(false); - let in_tags = item - .tags() - .iter() - .any(|t| t.to_lowercase().contains(&term_lower)); - if !in_desc && !in_body && !in_tags { - return false; - } - } - true - }) + .filter(|item| self.should_show_item(item)) .collect(); items.sort_by(|a, b| { b.timestamp() @@ -480,6 +461,8 @@ impl App { if self.view != view { self.view = view; self.selected_index = 0; + // Ids are per view (archive ids differ), so an id filter can't carry over. + self.filter.id_filter = None; // Persist the view choice self.config.default_view = view; diff --git a/crates/taskbook-client/src/tui/autocomplete.rs b/crates/taskbook-client/src/tui/autocomplete.rs index 385c455..a6b104b 100644 --- a/crates/taskbook-client/src/tui/autocomplete.rs +++ b/crates/taskbook-client/src/tui/autocomplete.rs @@ -9,6 +9,7 @@ const COMMANDS: &[(&str, &str)] = &[ ("move", "Move item to board"), ("delete", "Delete items"), ("search", "Search/filter items"), + ("show", "Show only the given item ids"), ("priority", "Set task priority"), ("check", "Toggle task check"), ("star", "Toggle star"), @@ -28,7 +29,7 @@ const COMMANDS: &[(&str, &str)] = &[ /// Commands that accept item ids const ITEM_COMMANDS: &[&str] = &[ - "check", "star", "begin", "delete", "edit", "move", "priority", "tag", + "check", "star", "begin", "delete", "edit", "move", "priority", "tag", "show", ]; const MAX_SUGGESTIONS: usize = 8; diff --git a/crates/taskbook-client/src/tui/command_parser.rs b/crates/taskbook-client/src/tui/command_parser.rs index b32c5b7..da2a4d4 100644 --- a/crates/taskbook-client/src/tui/command_parser.rs +++ b/crates/taskbook-client/src/tui/command_parser.rs @@ -33,6 +33,9 @@ pub enum ParsedCommand { Search { term: String, }, + Show { + ids: Vec, + }, Priority { id: u64, level: Priority, @@ -120,6 +123,7 @@ pub fn parse_command(input: &str) -> Result { Ok(ParsedCommand::Search { term }) } } + "show" => parse_id_list(args).map(|ids| ParsedCommand::Show { ids }), "priority" => parse_priority(args), "check" => parse_id_list(args).map(|ids| ParsedCommand::Check { ids }), "star" => parse_id_list(args).map(|ids| ParsedCommand::Star { ids }), @@ -550,6 +554,16 @@ mod tests { } } + #[test] + fn test_parse_show() { + assert!(matches!( + parse_command("/show 3 4").unwrap(), + ParsedCommand::Show { ids } if ids == [3, 4] + )); + assert!(parse_command("/show").is_err()); + assert!(parse_command("/show @3").is_err()); + } + #[test] fn test_ids_are_bare_numbers() { assert!(matches!( diff --git a/crates/taskbook-client/src/tui/widgets/help_popup.rs b/crates/taskbook-client/src/tui/widgets/help_popup.rs index f18e994..67494b4 100644 --- a/crates/taskbook-client/src/tui/widgets/help_popup.rs +++ b/crates/taskbook-client/src/tui/widgets/help_popup.rs @@ -159,6 +159,10 @@ pub fn render_help_popup(frame: &mut Frame, app: &App, scroll: u16) { Span::styled(" /search ", cmd_style), Span::styled("", desc_style), ]), + Line::from(vec![ + Span::styled(" /show ", cmd_style), + Span::styled(" [id...] only these items (Esc clears)", desc_style), + ]), Line::from(vec![ Span::styled(" /tag ", cmd_style), Span::styled(" +add -remove", desc_style), diff --git a/crates/taskbook-client/src/tui/widgets/journal_view.rs b/crates/taskbook-client/src/tui/widgets/journal_view.rs index 9ed874c..5c75679 100644 --- a/crates/taskbook-client/src/tui/widgets/journal_view.rs +++ b/crates/taskbook-client/src/tui/widgets/journal_view.rs @@ -44,22 +44,7 @@ pub fn render_journal_view(frame: &mut Frame, app: &App, area: Rect) { // Filter items for display let visible_items: Vec<&StorageItem> = date_items .iter() - .filter(|item| { - if app.filter.hide_completed { - if let Some(task) = item.as_task() { - if task.is_complete { - return false; - } - } - } - if let Some(ref term) = app.filter.search_term { - let term_lower = term.to_lowercase(); - if !item.description().to_lowercase().contains(&term_lower) { - return false; - } - } - true - }) + .filter(|item| app.should_show_item(item)) .copied() .collect(); diff --git a/crates/taskbook-client/src/tui/widgets/status_bar.rs b/crates/taskbook-client/src/tui/widgets/status_bar.rs index 25da2e9..dbc3a29 100644 --- a/crates/taskbook-client/src/tui/widgets/status_bar.rs +++ b/crates/taskbook-client/src/tui/widgets/status_bar.rs @@ -23,6 +23,23 @@ pub fn render_stats_line(frame: &mut Frame, app: &App, area: Rect) { return; } + // Id filter indicator + if let Some(ref ids) = app.filter.id_filter { + let ids = ids + .iter() + .map(u64::to_string) + .collect::>() + .join(", "); + let line = Line::from(vec![ + Span::raw(" "), + Span::styled("Showing: ", app.theme.info), + Span::styled(ids, app.theme.info.add_modifier(Modifier::BOLD)), + Span::styled(" (Esc to clear)", app.theme.muted), + ]); + frame.render_widget(Paragraph::new(line), area); + return; + } + // Search indicator if let Some(ref term) = app.filter.search_term { let search_line = Line::from(vec![ diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5d6522b..9a425fa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -47,6 +47,25 @@ tb archive Prints archived (deleted) items. The ids shown here are archive ids, used by `tb restore`. +### Show Items + +```bash +tb show ... [--archived] +``` + +Prints every field of the given items: state, priority, due date, boards, +tags, star, creation date, and the full body of notes. `--archived` looks up +archive ids (as shown by `tb archive`). + +```bash +tb show 3 +tb show 3 4 +tb show --archived 1 +``` + +In the TUI, `/show ...` narrows the current view to those items; Esc +clears it. + ## Creating Items ### Create Task From dffe0f9c076d08f9e940ad5e2c23cdbec0fc7676 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:30:05 +0000 Subject: [PATCH 4/4] Address review findings on the 2.0 changes - ids: require all-digit tokens. u64::from_str accepts "+2", so `tb tag 1 +2 +work` tagged item 2 instead of adding tag "2". - priority: when both fields are present, read the legacy `priority`. The Node.js taskbook edits JSON in place, so it can change `priority` and leave a stale `_priority`; 2.x always writes them consistently, so a disagreement means an older writer changed it. Out-of-range `_priority` now reads as normal instead of high. - legacy shim: warn that priority levels are inverted when a legacy invocation mentions a priority; drop `--cli` anywhere (1.x accepted any order); `tb --cli check 1` now names `tb check`. Restore `-V`. - MCP: reject wrongly typed arguments instead of reading them as empty (`"tags": "x"` on edit_item cleared all tags). - list: dedupe named boards (non-adjacent duplicates were shown twice); find prints no stats and list prints stats of all items, as in 1.x. - TUI: route every non-terminal error (view switches, sync refreshes) to the status bar; terminal errors still end the TUI. /search and board filtering clear an active /show filter. - toggles report map keys rather than stored `_id`s; rename_board dedupes when an item already has the target board. - docs: the legacy flags translate only the flag, not `@id` or the priority scale; document the priority read rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014rB6uhLWQEZyDE3XSNsyTZ --- CLAUDE.md | 2 +- crates/taskbook-client/src/cli.rs | 76 ++++++++++++++++----- crates/taskbook-client/src/commands.rs | 32 ++++++--- crates/taskbook-client/src/main.rs | 19 ++++-- crates/taskbook-client/src/mcp/mod.rs | 31 +++++++++ crates/taskbook-client/src/mcp/tools.rs | 82 ++++++++++++----------- crates/taskbook-client/src/taskbook.rs | 37 +++++----- crates/taskbook-client/src/tui/actions.rs | 49 ++++++++------ crates/taskbook-client/src/tui/app.rs | 1 + crates/taskbook-client/src/tui/mod.rs | 18 ++--- crates/taskbook-common/src/id.rs | 35 +++++++--- crates/taskbook-common/src/models/task.rs | 30 ++++++--- crates/taskbook-common/src/priority.rs | 12 ++-- docs/upgrading-to-2.0.md | 24 ++++--- 14 files changed, 302 insertions(+), 146 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 585d9f9..366ff66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ crates/ 2. **Client-Side Encryption**: All data is encrypted with AES-256-GCM before being sent to the server. The 32-byte encryption key is generated on registration and never leaves the client. Each item is encrypted individually with a unique random nonce. -3. **Backward Compatible JSON Format**: Uses `#[serde(rename = "...")]` to match the original Node.js field names (`_id`, `_date`, `_isTask`, `isStarred`, etc.) for seamless data migration. Priority is the exception: the authoritative field is `_priority` (1 = high, 2 = medium, 3 = normal); the legacy `priority` field (1 = normal … 3 = high) is still written for 1.x/Node.js readers and read only when `_priority` is absent (`Task` serializes through `TaskRepr` in `models/task.rs`). +3. **Backward Compatible JSON Format**: Uses `#[serde(rename = "...")]` to match the original Node.js field names (`_id`, `_date`, `_isTask`, `isStarred`, etc.) for seamless data migration. Priority is the exception: 2.x writes both `_priority` (1 = high, 2 = medium, 3 = normal) and the legacy `priority` (1 = normal … 3 = high). On read the legacy field wins when present (older writers may have changed it in place and left `_priority` stale); `_priority` is read only when `priority` is absent, the shape once 3.0 stops writing it (`Task` serializes through `TaskRepr` in `models/task.rs`). 4. **Atomic Writes**: Local storage operations write to a temp file first, then rename to prevent data corruption on crash. diff --git a/crates/taskbook-client/src/cli.rs b/crates/taskbook-client/src/cli.rs index 7089d18..a143a55 100644 --- a/crates/taskbook-client/src/cli.rs +++ b/crates/taskbook-client/src/cli.rs @@ -40,7 +40,7 @@ pub struct Cli { pub taskbook_dir: Option, /// Print version - #[arg(short = 'v', long, action = ArgAction::Version)] + #[arg(short = 'v', short_alias = 'V', long, action = ArgAction::Version)] version: Option, #[command(subcommand)] @@ -286,12 +286,18 @@ fn legacy_subcommand(arg: &str) -> Option<&'static str> { /// `tb --cli`) into subcommand form (`tb check 1`, `tb task @work x`, /// `tb board`). /// -/// Returns the rewritten arguments plus, when a legacy flag was replaced, the -/// `(flag, subcommand)` pair so the caller can print a deprecation notice. -/// Arguments that already name a subcommand are returned unchanged. -pub fn rewrite_legacy_args( - mut args: Vec, -) -> (Vec, Option<(String, &'static str)>) { +/// Returns the rewritten arguments plus, when a legacy flag was replaced or +/// dropped, the `(flag, subcommand)` pair so the caller can print a +/// deprecation notice. Arguments that already name a subcommand are left +/// alone apart from a leading `--cli`. +pub fn rewrite_legacy_args(mut args: Vec) -> (Vec, Option<(String, String)>) { + let is_subcommand = |arg: Option<&OsString>| { + let name = arg.and_then(|a| a.to_str()).unwrap_or(""); + Cli::command() + .get_subcommands() + .any(|sub| sub.get_name() == name) + }; + // Skip leading global options; drop the 1.x `--cli` switch. let mut start = 1; let mut saw_cli = false; @@ -307,22 +313,36 @@ pub fn rewrite_legacy_args( break; } } + let start = start.min(args.len()); - let first = args.get(start).and_then(|a| a.to_str()).unwrap_or(""); - let is_subcommand = Cli::command() - .get_subcommands() - .any(|sub| sub.get_name() == first); - if !is_subcommand { - let legacy = args[start.min(args.len())..] + if !is_subcommand(args.get(start)) { + // 1.x accepted flags in any order: drop `--cli` anywhere before `--`. + let end = args[start..] + .iter() + .position(|a| a == "--") + .map_or(args.len(), |i| start + i); + let before = args.len(); + let mut i = start; + let mut end = end; + while i < end { + if args[i] == "--cli" { + args.remove(i); + end -= 1; + } else { + i += 1; + } + } + saw_cli |= args.len() != before; + + let legacy = args[start..end] .iter() - .take_while(|a| *a != "--") .position(|a| a.to_str().and_then(legacy_subcommand).is_some()); if let Some(offset) = legacy { let flag = args.remove(start + offset); let flag = flag.to_string_lossy().into_owned(); let sub = legacy_subcommand(&flag).expect("matched above"); args.insert(start, sub.into()); - return (args, Some((flag, sub))); + return (args, Some((flag, sub.to_string()))); } } @@ -330,7 +350,8 @@ pub fn rewrite_legacy_args( if args.len() <= start { args.push("board".into()); } - return (args, Some(("--cli".to_string(), "board"))); + let sub = args[start].to_string_lossy().into_owned(); + return (args, Some(("--cli".to_string(), sub))); } (args, None) } @@ -413,6 +434,29 @@ mod tests { ); } + #[test] + fn test_rewrite_cli_flag_anywhere() { + assert_eq!( + rewrite(&["tb", "--list", "done", "--cli"]).0, + ["tb", "list", "done"] + ); + assert_eq!( + rewrite(&["tb", "--cli", "check", "1"]), + ( + vec!["tb".into(), "check".into(), "1".into()], + Some("--cli".into()) + ) + ); + } + + #[test] + fn test_short_version_aliases() { + for flag in ["-v", "-V", "--version"] { + let err = parse(&["tb", flag]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion, "{flag}"); + } + } + #[test] fn test_ids_reject_at_prefix_with_hint() { let err = parse(&["tb", "check", "@3"]).unwrap_err().to_string(); diff --git a/crates/taskbook-client/src/commands.rs b/crates/taskbook-client/src/commands.rs index a654471..0c1e36f 100644 --- a/crates/taskbook-client/src/commands.rs +++ b/crates/taskbook-client/src/commands.rs @@ -65,7 +65,10 @@ pub fn run(cli: Cli) -> Result<()> { fn run_item_command(tb: &Taskbook, render: &Render, command: Command) -> Result<()> { match command { - Command::Board => show_boards(render, &tb.items()?, None), + Command::Board => { + let data = tb.items()?; + show_boards(render, &data, None, Some(&data)); + } Command::Timeline => { let data = tb.items()?; render.display_by_date(&group_by_date(&data)); @@ -187,13 +190,14 @@ fn run_item_command(tb: &Taskbook, render: &Render, command: Command) -> Result< Command::Find { terms } => { let mut data = tb.items()?; data.retain(|_, item| item_matches_terms(item, &terms)); - show_boards(render, &data, None); + show_boards(render, &data, None, None); } Command::List { filters } => { - let mut data = tb.items()?; - let known_boards = Taskbook::boards_of(&data); + let all = tb.items()?; + let mut data = all.clone(); + let known_boards = Taskbook::boards_of(&all); let boards = apply_list_filters(&mut data, &filters, &known_boards)?; - show_boards(render, &data, boards); + show_boards(render, &data, boards, Some(&all)); } Command::Clear => { let ids = tb.clear_completed()?; @@ -214,11 +218,13 @@ fn run_item_command(tb: &Taskbook, render: &Render, command: Command) -> Result< } /// Render `data` grouped by board (all of its boards, or just `only`), then -/// the overall stats. -fn show_boards(render: &Render, data: &Items, only: Option>) { +/// the progress stats of `stats_of` when given. +fn show_boards(render: &Render, data: &Items, only: Option>, stats_of: Option<&Items>) { let boards = only.unwrap_or_else(|| Taskbook::boards_of(data)); render.display_by_board(&group_by_board(data, &boards)); - render.display_stats(&Stats::from_items(data.values())); + if let Some(all) = stats_of { + render.display_stats(&Stats::from_items(all.values())); + } } fn parse_due_value(raw: &str) -> Result> { @@ -289,6 +295,11 @@ fn apply_list_filters( }; let mut boards: Vec = Vec::new(); + let mut add_board = |b: String| { + if !boards.iter().any(|existing| board::board_eq(existing, &b)) { + boards.push(b); + } + }; for term in filters { if let Some(tag) = term.strip_prefix('+').filter(|t| !t.is_empty()) { let tag = board::normalize_tag(tag); @@ -297,11 +308,11 @@ fn apply_list_filters( let b = find_board(term).ok_or_else(|| { TaskbookError::UnknownBoard(board::display_name(&board::normalize_board_name(term))) })?; - boards.push(b); + add_board(b); } else if let Some(pred) = list_attribute(&term.to_lowercase()) { data.retain(|_, item| pred(item)); } else if let Some(b) = find_board(term) { - boards.push(b); + add_board(b); } else { return Err(TaskbookError::UnknownFilter(term.clone())); } @@ -310,7 +321,6 @@ fn apply_list_filters( if boards.is_empty() { return Ok(None); } - boards.dedup_by(|a, b| board::board_eq(a, b)); Ok(Some(boards)) } diff --git a/crates/taskbook-client/src/main.rs b/crates/taskbook-client/src/main.rs index 20020c5..3477cf8 100644 --- a/crates/taskbook-client/src/main.rs +++ b/crates/taskbook-client/src/main.rs @@ -20,15 +20,26 @@ mod tui; fn main() { let (args, legacy) = cli::rewrite_legacy_args(std::env::args_os().collect()); - let cli = cli::Cli::parse_from(args); - if let Some((flag, subcommand)) = legacy { + if let Some((flag, subcommand)) = &legacy { + let warning = "warning:".yellow().bold(); eprintln!( - "{} `tb {flag}` is deprecated and will be removed in 3.0; use `tb {subcommand}`", - "warning:".yellow().bold(), + "{warning} `tb {flag}` is deprecated and will be removed in 3.0; use `tb {subcommand}`" ); + // A 1.x script's priority numbers now mean the opposite. + let mentions_priority = subcommand == "priority" + || args + .iter() + .any(|a| a.to_str().is_some_and(|a| a.starts_with("p:"))); + if mentions_priority { + eprintln!( + "{warning} priority levels are inverted since 2.0: 1 = high, 2 = medium, 3 = normal" + ); + } } + let cli = cli::Cli::parse_from(args); + if let Err(e) = commands::run(cli) { eprintln!("\n {} {}\n", "✖".red(), e); process::exit(1); diff --git a/crates/taskbook-client/src/mcp/mod.rs b/crates/taskbook-client/src/mcp/mod.rs index 92c2938..4e3c2a0 100644 --- a/crates/taskbook-client/src/mcp/mod.rs +++ b/crates/taskbook-client/src/mcp/mod.rs @@ -435,4 +435,35 @@ mod tests { ); assert_eq!(resp["result"]["isError"], true); } + + #[test] + fn wrongly_typed_arguments_are_errors_not_empty_values() { + let tb = test_taskbook(); + request( + &tb, + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"create_task","arguments":{"description":"A","tags":["keep"]} + }}), + ); + for args in [ + json!({"id":1,"tags":"urgent"}), + json!({"id":1,"starred":"yes"}), + json!({"id":1,"boards":["ok", 3]}), + ] { + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ + "name":"edit_item","arguments":args + }}), + ); + assert_eq!(resp["result"]["isError"], true, "{args}"); + } + let resp = request( + &tb, + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{ + "name":"list_items","arguments":{} + }}), + ); + assert_eq!(tool_payload(&resp)[0]["tags"], json!(["keep"])); + } } diff --git a/crates/taskbook-client/src/mcp/tools.rs b/crates/taskbook-client/src/mcp/tools.rs index c508d83..42773ba 100644 --- a/crates/taskbook-client/src/mcp/tools.rs +++ b/crates/taskbook-client/src/mcp/tools.rs @@ -185,16 +185,35 @@ fn arg_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> { .ok_or_else(|| format!("missing or invalid '{key}'")) } -fn opt_string_vec(args: &Value, key: &str) -> Vec { - args.get(key) - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() +/// An optional array of strings. A present value of any other shape is an +/// error rather than being read as empty. +fn opt_string_vec(args: &Value, key: &str) -> Result>, String> { + let invalid = || format!("'{key}' must be an array of strings"); + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(arr)) => arr + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(invalid)) + .collect::, _>>() + .map(Some), + Some(_) => Err(invalid()), + } +} + +fn opt_str<'a>(args: &'a Value, key: &str) -> Result, String> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(format!("'{key}' must be a string")), + } +} + +fn opt_bool(args: &Value, key: &str) -> Result, String> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("'{key}' must be a boolean")), + } } /// A non-empty array of ids. Any non-integer element is an error rather than @@ -236,10 +255,7 @@ fn parse_priority_arg(args: &Value) -> Result, String> { } fn list_items(taskbook: &Taskbook, args: &Value) -> Result { - let archived = args - .get("archived") - .and_then(Value::as_bool) - .unwrap_or(false); + let archived = opt_bool(args, "archived")?.unwrap_or(false); let data = if archived { taskbook.archived_items() } else { @@ -247,19 +263,16 @@ fn list_items(taskbook: &Taskbook, args: &Value) -> Result { } .map_err(|e| e.to_string())?; - let filter = args.get("filter").and_then(Value::as_str); + let filter = opt_str(args, "filter")?; if let Some(f) = filter { let valid = ["pending", "in_progress", "done", "task", "note", "starred"]; if !valid.contains(&f) { return Err(format!("invalid filter '{f}' (use one of {valid:?})")); } } - let board_filter = args.get("board").and_then(Value::as_str); - let tag_filter = args.get("tag").and_then(Value::as_str); - let query = args - .get("query") - .and_then(Value::as_str) - .map(str::to_lowercase); + let board_filter = opt_str(args, "board")?; + let tag_filter = opt_str(args, "tag")?; + let query = opt_str(args, "query")?.map(str::to_lowercase); let mut items: Vec<&StorageItem> = data.values().collect(); items.retain(|item| { @@ -328,10 +341,10 @@ fn create_task(taskbook: &Taskbook, args: &Value) -> Result { let id = taskbook .create_task(NewTask { description: arg_str(args, "description")?.to_string(), - boards: opt_string_vec(args, "boards"), + boards: opt_string_vec(args, "boards")?.unwrap_or_default(), priority: parse_priority_arg(args)?.unwrap_or_default(), - tags: opt_string_vec(args, "tags"), - due_date: match args.get("due_date").and_then(Value::as_str) { + tags: opt_string_vec(args, "tags")?.unwrap_or_default(), + due_date: match opt_str(args, "due_date")? { Some(raw) => parse_due_arg(raw)?, None => None, }, @@ -344,9 +357,9 @@ fn create_note(taskbook: &Taskbook, args: &Value) -> Result { let id = taskbook .create_note(NewNote { title: arg_str(args, "description")?.to_string(), - boards: opt_string_vec(args, "boards"), + boards: opt_string_vec(args, "boards")?.unwrap_or_default(), body: None, - tags: opt_string_vec(args, "tags"), + tags: opt_string_vec(args, "tags")?.unwrap_or_default(), }) .map_err(|e| e.to_string())?; Ok(json!({ "id": id, "type": "note" })) @@ -368,19 +381,12 @@ fn set_task_state(taskbook: &Taskbook, args: &Value) -> Result { fn edit_item(taskbook: &Taskbook, args: &Value) -> Result { let id = arg_u64(args, "id")?; let edit = ItemEdit { - description: args - .get("description") - .and_then(Value::as_str) - .map(str::to_string), + description: opt_str(args, "description")?.map(str::to_string), priority: parse_priority_arg(args)?, - due_date: args - .get("due_date") - .and_then(Value::as_str) - .map(parse_due_arg) - .transpose()?, - boards: args.get("boards").map(|_| opt_string_vec(args, "boards")), - tags: args.get("tags").map(|_| opt_string_vec(args, "tags")), - starred: args.get("starred").and_then(Value::as_bool), + due_date: opt_str(args, "due_date")?.map(parse_due_arg).transpose()?, + boards: opt_string_vec(args, "boards")?, + tags: opt_string_vec(args, "tags")?, + starred: opt_bool(args, "starred")?, }; let changed = edit.fields(); if changed.is_empty() { diff --git a/crates/taskbook-client/src/taskbook.rs b/crates/taskbook-client/src/taskbook.rs index 5d5b465..f39efcf 100644 --- a/crates/taskbook-client/src/taskbook.rs +++ b/crates/taskbook-client/src/taskbook.rs @@ -288,7 +288,7 @@ impl Taskbook { &self, ids: &[u64], tasks_only: bool, - mut f: impl FnMut(&mut StorageItem), + mut f: impl FnMut(u64, &mut StorageItem), ) -> Result> { let mut data = self.items()?; let ids = if tasks_only { @@ -297,22 +297,22 @@ impl Taskbook { resolve_ids(&data, ids)? }; for id in &ids { - f(data.get_mut(&id.to_string()).expect("resolved")); + f(*id, data.get_mut(&id.to_string()).expect("resolved")); } self.storage.set(&data)?; Ok(ids) } - fn update_tasks(&self, ids: &[u64], mut f: impl FnMut(&mut Task)) -> Result> { - self.update_items(ids, true, |item| { - f(item.as_task_mut().expect("resolved as task")) + fn update_tasks(&self, ids: &[u64], mut f: impl FnMut(u64, &mut Task)) -> Result> { + self.update_items(ids, true, |id, item| { + f(id, item.as_task_mut().expect("resolved as task")) }) } /// Toggle tasks between done and pending. Clears in-progress. pub fn toggle_check(&self, ids: &[u64]) -> Result { let mut out = Toggled::default(); - self.update_tasks(ids, |task| { + self.update_tasks(ids, |id, task| { task.in_progress = false; task.is_complete = !task.is_complete; let bucket = if task.is_complete { @@ -320,7 +320,7 @@ impl Taskbook { } else { &mut out.off }; - bucket.push(task.id); + bucket.push(id); })?; Ok(out) } @@ -328,7 +328,7 @@ impl Taskbook { /// Toggle tasks between in-progress and pending. Clears done. pub fn toggle_begin(&self, ids: &[u64]) -> Result { let mut out = Toggled::default(); - self.update_tasks(ids, |task| { + self.update_tasks(ids, |id, task| { task.is_complete = false; task.in_progress = !task.in_progress; let bucket = if task.in_progress { @@ -336,24 +336,24 @@ impl Taskbook { } else { &mut out.off }; - bucket.push(task.id); + bucket.push(id); })?; Ok(out) } pub fn toggle_star(&self, ids: &[u64]) -> Result { let mut out = Toggled::default(); - self.update_items(ids, false, |item| { + self.update_items(ids, false, |id, item| { let starred = !item.is_starred(); item.set_starred(starred); let bucket = if starred { &mut out.on } else { &mut out.off }; - bucket.push(item.id()); + bucket.push(id); })?; Ok(out) } pub fn set_task_state(&self, id: u64, state: TaskState) -> Result<()> { - self.update_tasks(&[id], |task| { + self.update_tasks(&[id], |_, task| { task.is_complete = state == TaskState::Done; task.in_progress = state == TaskState::InProgress; })?; @@ -361,11 +361,11 @@ impl Taskbook { } pub fn set_priority(&self, ids: &[u64], priority: Priority) -> Result> { - self.update_tasks(ids, |task| task.priority = priority) + self.update_tasks(ids, |_, task| task.priority = priority) } pub fn set_due_date(&self, ids: &[u64], due_date: Option) -> Result> { - self.update_tasks(ids, |task| task.due_date = due_date) + self.update_tasks(ids, |_, task| task.due_date = due_date) } /// Put items on exactly `boards`. Returns the normalized board names. @@ -374,7 +374,7 @@ impl Taskbook { return Err(TaskbookError::MissingBoard); } let boards = normalize_boards(boards); - self.update_items(ids, false, |item| item.set_boards(boards.clone()))?; + self.update_items(ids, false, |_, item| item.set_boards(boards.clone()))?; Ok(boards) } @@ -390,7 +390,7 @@ impl Taskbook { if add.is_empty() && remove.is_empty() { return Err(TaskbookError::MissingTags); } - self.update_items(ids, false, |item| { + self.update_items(ids, false, |_, item| { let mut tags: Vec = item.tags().to_vec(); tags.retain(|t| !remove.iter().any(|r| t.eq_ignore_ascii_case(r))); for tag in &add { @@ -422,7 +422,7 @@ impl Taskbook { let tags = tags.map(normalize_tags); let tasks_only = priority.is_some() || due_date.is_some(); - self.update_items(&[id], tasks_only, |item| { + self.update_items(&[id], tasks_only, |_, item| { if let Some(d) = &description { item.set_description(d.clone()); } @@ -547,7 +547,8 @@ impl Taskbook { } }) .collect(); - item.set_boards(boards); + // Dedupes when the item was already on the new board. + item.set_boards(normalize_boards(boards)); count += 1; } } diff --git a/crates/taskbook-client/src/tui/actions.rs b/crates/taskbook-client/src/tui/actions.rs index 599850c..ce81da7 100644 --- a/crates/taskbook-client/src/tui/actions.rs +++ b/crates/taskbook-client/src/tui/actions.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::editor; -use crate::error::Result; +use crate::error::{Result, TaskbookError}; use crate::taskbook::{NewNote, NewTask}; use taskbook_common::{board, Priority}; @@ -50,11 +50,11 @@ fn handle_confirm_key(app: &mut App, key: KeyEvent) -> Result<()> { match action { PendingAction::Delete { ids } => { let result = delete_items(app, &ids); - report(app, result); + report(app, result)?; } PendingAction::Clear => { let result = clear_completed(app); - report(app, result); + report(app, result)?; } } } @@ -184,13 +184,16 @@ fn accept_suggestion(app: &mut App) { /// (unknown id, note given a priority, ...) go to the status bar instead of /// ending the TUI. fn execute_input(app: &mut App, input: &str) -> Result<()> { - let result = command_parser::parse_command(input) - .map_err(|e| e.message) - .and_then(|cmd| execute_command(app, cmd).map_err(|e| e.to_string())); - if let Err(message) = result { - app.set_status(message, StatusKind::Error); + match command_parser::parse_command(input) { + Ok(cmd) => { + let result = execute_command(app, cmd); + report(app, result) + } + Err(e) => { + app.set_status(e.message, StatusKind::Error); + Ok(()) + } } - Ok(()) } /// Explicit boards, else the filtered board, else the default board. @@ -264,6 +267,7 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { app.command_line.pending_confirm = Some(PendingAction::Delete { ids }); } ParsedCommand::Search { term } => { + app.filter.id_filter = None; app.filter.search_term = Some(term.clone()); app.update_display_order(); app.selected_index = 0; @@ -280,7 +284,7 @@ fn execute_command(app: &mut App, cmd: ParsedCommand) -> Result<()> { .filter(|id| !app.items.contains_key(&id.to_string())) .collect(); if !unknown.is_empty() { - return Err(crate::error::TaskbookError::UnknownIds(unknown)); + return Err(TaskbookError::UnknownIds(unknown)); } app.clear_board_filter(); app.filter.search_term = None; @@ -403,7 +407,7 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { if !item.is_task() { let id = item.id(); let result = edit_note_external(app, id); - report(app, result); + report(app, result)?; } else if app.view == ViewMode::Board && app.filter.board_filter.is_none() { if let Some(board_name) = app.get_board_for_selected() { let display = board::display_name(&board_name); @@ -493,31 +497,31 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { KeyCode::Char('c') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { let result = toggle_check(app, &[id]); - report(app, result); + report(app, result)?; } } KeyCode::Char('b') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { let result = toggle_begin(app, &[id]); - report(app, result); + report(app, result)?; } } KeyCode::Char('s') if app.view != ViewMode::Archive => { if let Some(id) = app.selected_id() { let result = toggle_star(app, &[id]); - report(app, result); + report(app, result)?; } } KeyCode::Char('r') if app.view == ViewMode::Archive => { if let Some(id) = app.selected_id() { let result = restore_item(app, id); - report(app, result); + report(app, result)?; } } KeyCode::Char('y') => { if let Some(id) = app.selected_id() { let result = copy_to_clipboard(app, id); - report(app, result); + report(app, result)?; } } @@ -549,9 +553,16 @@ fn handle_shortcut_key(app: &mut App, key: KeyEvent) -> Result<()> { // Action implementations /// Show an operation error in the status bar instead of ending the TUI. -fn report(app: &mut App, result: Result<()>) { - if let Err(e) = result { - app.set_status(e.to_string(), StatusKind::Error); +/// Terminal failures (`TaskbookError::Tui`) still propagate: the TUI cannot +/// keep running without a working terminal. +pub fn report(app: &mut App, result: Result<()>) -> Result<()> { + match result { + Err(e @ TaskbookError::Tui(_)) => Err(e), + Err(e) => { + app.set_status(e.to_string(), StatusKind::Error); + Ok(()) + } + Ok(()) => Ok(()), } } diff --git a/crates/taskbook-client/src/tui/app.rs b/crates/taskbook-client/src/tui/app.rs index 48a92de..a39facb 100644 --- a/crates/taskbook-client/src/tui/app.rs +++ b/crates/taskbook-client/src/tui/app.rs @@ -371,6 +371,7 @@ impl App { /// Set board filter pub fn set_board_filter(&mut self, board: Option) { + self.filter.id_filter = None; self.filter.board_filter = board; self.selected_index = 0; self.update_display_order(); diff --git a/crates/taskbook-client/src/tui/mod.rs b/crates/taskbook-client/src/tui/mod.rs index fd2d0d1..077068d 100644 --- a/crates/taskbook-client/src/tui/mod.rs +++ b/crates/taskbook-client/src/tui/mod.rs @@ -150,7 +150,8 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> match events.next()? { event::Event::Key(key) => { - actions::handle_key_event(app, key)?; + let result = actions::handle_key_event(app, key); + actions::report(app, result)?; } event::Event::Tick => { app.tick(); @@ -158,16 +159,17 @@ fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> event::Event::Resize(_, _) => {} event::Event::DataChanged { archived } => { use app::ViewMode; - match (app.view, archived) { - (ViewMode::Archive, true) => { - app.items = app.taskbook.archived_items()?; + let result = match (app.view, archived) { + (ViewMode::Archive, true) => app.taskbook.archived_items().map(|items| { + app.items = items; app.update_display_order(); - } + }), (ViewMode::Board | ViewMode::Timeline | ViewMode::Journal, false) => { - app.refresh_items()?; + app.refresh_items() } - _ => {} // Data will be loaded when user switches views - } + _ => Ok(()), // Data will be loaded when user switches views + }; + actions::report(app, result)?; } } } diff --git a/crates/taskbook-common/src/id.rs b/crates/taskbook-common/src/id.rs index 9838361..500ef98 100644 --- a/crates/taskbook-common/src/id.rs +++ b/crates/taskbook-common/src/id.rs @@ -32,10 +32,22 @@ impl fmt::Display for IdError { impl std::error::Error for IdError {} +/// All ASCII digits. Stricter than `u64::from_str`, which accepts `+2` +/// (and `+2` is a tag). +fn is_digits(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|b| b.is_ascii_digit()) +} + fn is_at_id(token: &str) -> bool { - token - .strip_prefix('@') - .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) + token.strip_prefix('@').is_some_and(is_digits) +} + +fn as_id(token: &str) -> Option { + if is_digits(token) { + token.parse().ok() + } else { + None + } } /// Parse a single id token. @@ -43,9 +55,7 @@ pub fn parse_id(token: &str) -> Result { if is_at_id(token) { return Err(IdError::AtPrefixed(token.to_string())); } - token - .parse::() - .map_err(|_| IdError::Invalid(token.to_string())) + as_id(token).ok_or_else(|| IdError::Invalid(token.to_string())) } /// Parse a list where every token must be an id. Fails on an empty list. @@ -64,7 +74,7 @@ pub fn parse_ids>(tokens: &[S]) -> Result, IdError> { pub fn split_leading_ids>(tokens: &[S]) -> Result<(Vec, &[S]), IdError> { let count = tokens .iter() - .take_while(|t| t.as_ref().parse::().is_ok()) + .take_while(|t| as_id(t.as_ref()).is_some()) .count(); if count == 0 { return Err(match tokens.first() { @@ -75,7 +85,7 @@ pub fn split_leading_ids>(tokens: &[S]) -> Result<(Vec, &[S]) } let ids = tokens[..count] .iter() - .map(|t| t.as_ref().parse::().expect("checked above")) + .map(|t| as_id(t.as_ref()).expect("checked above")) .collect(); Ok((ids, &tokens[count..])) } @@ -101,6 +111,7 @@ mod tests { assert_eq!(parse_id("foo"), Err(IdError::Invalid("foo".into()))); assert_eq!(parse_id("@foo"), Err(IdError::Invalid("@foo".into()))); assert_eq!(parse_id("-1"), Err(IdError::Invalid("-1".into()))); + assert_eq!(parse_id("+2"), Err(IdError::Invalid("+2".into()))); } #[test] @@ -118,6 +129,14 @@ mod tests { assert_eq!(rest, &["@work", "3"]); } + #[test] + fn test_split_leading_ids_stops_at_plus_tags() { + let tokens = ["1", "+2", "+work"]; + let (ids, rest) = split_leading_ids(&tokens).unwrap(); + assert_eq!(ids, vec![1]); + assert_eq!(rest, &["+2", "+work"]); + } + #[test] fn test_split_leading_ids_requires_an_id() { assert_eq!( diff --git a/crates/taskbook-common/src/models/task.rs b/crates/taskbook-common/src/models/task.rs index 9c1e380..fc63387 100644 --- a/crates/taskbook-common/src/models/task.rs +++ b/crates/taskbook-common/src/models/task.rs @@ -26,10 +26,14 @@ pub struct Task { /// On-disk / on-wire shape of a [`Task`]. /// /// Field names match the original Node.js taskbook. Priority is stored twice: -/// `_priority` (1 = high … 3 = normal) is authoritative; the legacy -/// `priority` field (1 = normal … 3 = high) is written for 1.x clients and -/// read only when `_priority` is absent. A 1.x client re-saving an item drops -/// `_priority` (it does not know the field), so the two never disagree. +/// `_priority` (1 = high … 3 = normal) and the legacy `priority` +/// (1 = normal … 3 = high). 2.x always writes both, consistently. +/// +/// On read, the legacy field wins whenever it is present: writers that only +/// know it may have changed it and left a stale `_priority` behind (the +/// Node.js taskbook edits its JSON in place; taskbook-rs 1.x drops +/// `_priority` instead). `_priority` alone is read when the legacy field is +/// absent, which is the shape once 3.0 stops writing `priority`. #[derive(Serialize, Deserialize)] struct TaskRepr { #[serde(rename = "_id")] @@ -61,9 +65,9 @@ struct TaskRepr { impl From for Task { fn from(r: TaskRepr) -> Self { - let priority = match (r.priority, r.legacy_priority) { - (Some(p), _) => Priority::from_stored(p), - (None, Some(legacy)) => Priority::from_legacy(legacy), + let priority = match (r.legacy_priority, r.priority) { + (Some(legacy), _) => Priority::from_legacy(legacy), + (None, Some(p)) => Priority::from_stored(p), (None, None) => Priority::default(), }; Self { @@ -220,9 +224,17 @@ mod tests { } #[test] - fn test_new_priority_field_wins_over_legacy() { - let both = LEGACY_HIGH.replace(r#""priority":3"#, r#""priority":3,"_priority":2"#); + fn test_legacy_field_wins_when_an_older_writer_changed_it() { + // 2.x wrote _priority:3/priority:1 (normal); Node then set priority:3. + let both = LEGACY_HIGH.replace(r#""priority":3"#, r#""priority":3,"_priority":3"#); let back: Task = serde_json::from_str(&both).unwrap(); + assert_eq!(back.priority, Priority::High); + } + + #[test] + fn test_new_field_alone_is_read() { + let new_only = LEGACY_HIGH.replace(r#""priority":3"#, r#""_priority":2"#); + let back: Task = serde_json::from_str(&new_only).unwrap(); assert_eq!(back.priority, Priority::Medium); } diff --git a/crates/taskbook-common/src/priority.rs b/crates/taskbook-common/src/priority.rs index 70bdd77..eb4bab2 100644 --- a/crates/taskbook-common/src/priority.rs +++ b/crates/taskbook-common/src/priority.rs @@ -6,8 +6,8 @@ //! //! Taskbook 1.x and the original Node.js taskbook stored the inverse scale //! in the `priority` field (1 = normal … 3 = high). Tasks still write that -//! legacy field alongside `_priority` so 1.x clients sharing the same data -//! read the right value, and it is read back when `_priority` is absent. +//! legacy field alongside `_priority` so older clients sharing the same data +//! read the right value; see `models/task.rs` for which field is read. use std::fmt; use std::str::FromStr; @@ -43,9 +43,9 @@ impl Priority { } } - /// Decode a stored `_priority` value, clamping out-of-range values. + /// Decode a stored `_priority` value; anything out of range is Normal. pub(crate) fn from_stored(level: u8) -> Self { - Self::from_level(level.clamp(1, 3)).unwrap_or_default() + Self::from_level(level).unwrap_or_default() } /// Decode the legacy (1.x / Node.js) `priority` field, where larger is @@ -154,8 +154,8 @@ mod tests { } #[test] - fn test_from_stored_clamps() { - assert_eq!(Priority::from_stored(0), Priority::High); + fn test_from_stored_out_of_range_is_normal() { + assert_eq!(Priority::from_stored(0), Priority::Normal); assert_eq!(Priority::from_stored(9), Priority::Normal); } diff --git a/docs/upgrading-to-2.0.md b/docs/upgrading-to-2.0.md index 353e5b1..5404bca 100644 --- a/docs/upgrading-to-2.0.md +++ b/docs/upgrading-to-2.0.md @@ -16,10 +16,14 @@ Actions are subcommands instead of flags. | `tb --register --server URL` | `tb register --server URL` | | `tb --mcp` | `tb mcp` | -The 1.x flags (`--task`/`-t`, `--check`/`-c`, ...) still work in 2.x: they -are rewritten to the matching subcommand and print a deprecation warning on -stderr. They will be removed in 3.0. Update scripts and your MCP -registration: +The 1.x flags (`--task`/`-t`, `--check`/`-c`, ...) still parse in 2.x: +they are rewritten to the matching subcommand and print a deprecation +warning on stderr. They will be removed in 3.0. The flag is the only thing +translated, though: the arguments must already follow the 2.0 rules below. +So `tb --edit @3 x` fails (ids lose the `@`), and `tb --task Fix p:3` now +means *normal* priority (the scale is inverted). The shim prints an extra +warning when a legacy invocation mentions a priority. Update scripts and +your MCP registration: ```bash claude mcp remove taskbook @@ -63,13 +67,17 @@ and to the MCP `priority` field (input and output). Labels work too: `p:high`, `tb priority 3 medium`. **Stored data.** 2.0 writes the new scale to a new `_priority` field and -also keeps writing the old `priority` field with the 1.x value, so: +keeps writing the old `priority` field with the 1.x value, so: - Existing 1.x data (and data from the Node.js taskbook) is read correctly; nothing to migrate. -- A 1.x client sharing the same directory or sync server keeps reading - correct priorities from items 2.0 wrote. When 1.x saves an item it drops - `_priority`, and 2.0 falls back to the legacy field. +- A 1.x or Node.js client sharing the same directory or sync server keeps + reading correct priorities from items 2.0 wrote. +- While both fields are written, 2.x reads the legacy `priority` field + whenever it is present, because an older client may have changed it and + left `_priority` stale. `_priority` alone is read only when `priority` is + absent, which is the shape once 3.0 stops writing the legacy field. If you + edit the JSON by hand, change `priority` (or both). ## Stricter input