Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846use std::collections::{BTreeMap, HashSet};
use anyhow::{Context as _, Result, bail};use clap::{ArgGroup, Args};
use crate::{ app::Cli, arg_types::IdentifierToken, commands::Command, common::{DIM, GREEN, ICONS, colored, one_line, shown_title}, wire::{ checklist::ChecklistItemPatch, recurrence::RecurrenceType, task::{TaskPatch, TaskStatus}, wire_object::{EntityType, WireObject}, },};
#[derive(Args)]#[command(about = "Mark a task completed, incomplete, or canceled")]#[command(group(ArgGroup::new("status").args(["done", "incomplete", "canceled", "check_ids", "uncheck_ids", "check_cancel_ids"]).required(true).multiple(false)))]pub struct MarkArgs { /// Task ID(s) (or unique ID prefixes) #[arg(required = true)] pub task_ids: Vec<IdentifierToken>, #[arg(long, short = 'd', help = "Mark task(s) as completed")] pub done: bool, #[arg(long, short = 'i', help = "Mark task(s) as incomplete")] pub incomplete: bool, #[arg(long, short = 'c', help = "Mark task(s) as canceled")] pub canceled: bool, #[arg( long = "check", short = 'k', help = "Mark checklist items completed by comma-separated ID prefixes" )] pub check_ids: Option<String>, #[arg( long = "uncheck", short = 'u', help = "Mark checklist items incomplete by comma-separated ID prefixes" )] pub uncheck_ids: Option<String>, #[arg( long = "check-cancel", short = 'x', help = "Mark checklist items canceled by comma-separated ID prefixes" )] pub check_cancel_ids: Option<String>,}
fn resolve_checklist_items( task: &crate::store::Task, raw_ids: &str,) -> (Vec<crate::store::ChecklistItem>, String) { let tokens = raw_ids .split(',') .map(str::trim) .filter(|t| !t.is_empty()) .collect::<Vec<_>>(); if tokens.is_empty() { return (Vec::new(), "No checklist item IDs provided.".to_string()); }
let mut resolved = Vec::new(); let mut seen = HashSet::new(); for token in tokens { let matches = task .checklist_items .iter() .filter(|item| item.uuid.starts_with(token)) .cloned() .collect::<Vec<_>>(); if matches.is_empty() { return (Vec::new(), format!("Checklist item not found: '{token}'")); } if matches.len() > 1 { return ( Vec::new(), format!( "Ambiguous checklist item prefix '{token}' ({} matches).", matches.len() ), ); } let item = matches[0].clone(); if seen.insert(item.uuid.clone()) { resolved.push(item); } }
(resolved, String::new())}
/// a status change of a repeat's instance////// the app follows it with template bookkeeping for after completion rulesfn validate_recurring_instance( task: &crate::store::Task, store: &crate::store::ThingsStore,) -> (bool, String) { if task.recurrence_templates.len() != 1 { return ( false, format!( "This repeating item names {} templates where one is expected.", task.recurrence_templates.len() ), ); }
// the store keeps a template id while the state holds the template // one that did not replay completely is no task let Some(template) = store.get_task(&task.recurrence_templates[0].to_string()) else { return ( false, "The template of this repeating item did not replay completely.".to_string(), ); };
let Some(rr) = template.recurrence_rule else { return ( false, "The template of this repeating item has no repeat rule.".to_string(), ); };
match rr.recurrence_type { RecurrenceType::FixedSchedule => (true, String::new()), RecurrenceType::AfterCompletion => ( false, "Only an Apple client completes or cancels an item that repeats after completion." .to_string(), ), RecurrenceType::Unknown(v) => ( false, format!("The template has a repeat type the CLI does not know: rr.tp={v:?}."), ), }}
fn validate_mark_target( task: &crate::store::Task, action: &str, store: &crate::store::ThingsStore,) -> String { if task.has_repeater() { return "Cannot change the status of an item with a repeater, whose bookkeeping the Apple clients keep." .to_string(); } if task.is_recurrence_template() { return "A repeat template takes no status, mark one of its instances instead.".to_string(); } if matches!(task.status, TaskStatus::Unknown(_)) { return "Item is of an unknown status.".to_string(); } if action == "completed" && task.status == TaskStatus::Completed { return "Item is already completed.".to_string(); } if action == "incomplete" && task.status == TaskStatus::Incomplete { return "Item is already incomplete.".to_string(); } if action == "canceled" && task.status == TaskStatus::Canceled { return "Item is already canceled.".to_string(); } if task.is_recurrence_instance() { let (ok, reason) = validate_recurring_instance(task, store); if !ok { return reason; } } String::new()}
struct MarkCommitPlan { changes: BTreeMap<String, WireObject>,}
fn build_mark_status_plan( args: &MarkArgs, store: &crate::store::ThingsStore, now: f64,) -> (MarkCommitPlan, Vec<crate::store::Task>, Vec<String>) { let action = if args.done { "completed" } else if args.incomplete { "incomplete" } else { "canceled" };
let mut targets = Vec::new(); let mut seen = HashSet::new(); let mut errors = Vec::new(); for identifier in &args.task_ids { let (task_opt, err, _) = store.resolve_mark_identifier(identifier.as_str()); let Some(task) = task_opt else { errors.push(err); continue; }; if !seen.insert(task.uuid.clone()) { continue; } targets.push(task); }
let mut updates = Vec::new(); let mut successes = Vec::new();
for task in targets { let validation_error = validate_mark_target(&task, action, store); if !validation_error.is_empty() { errors.push(format!( "{} ({})", validation_error, shown_title(&task.title) )); continue; }
let (task_status, stop_date) = if action == "completed" { (TaskStatus::Completed, Some(now)) } else if action == "incomplete" { (TaskStatus::Incomplete, None) } else { (TaskStatus::Canceled, Some(now)) };
// a project closes with its incomplete to-dos // those to-dos take its status as in the app once it asks if task.is_project() && action != "incomplete" { let mut open = store .tasks_by_uuid .values() .filter(|child| { child.is_todo() && !store.in_trash(child) && child.status == TaskStatus::Incomplete && !child.is_recurrence_template() && !seen.contains(&child.uuid) && store.effective_project_uuid(child).as_ref() == Some(&task.uuid) }) .cloned() .collect::<Vec<_>>(); open.sort_by(|a, b| (a.index, &a.uuid).cmp(&(b.index, &b.uuid))); for child in open { let validation_error = validate_mark_target(&child, action, store); if !validation_error.is_empty() { errors.push(format!( "{} ({}, in {})", validation_error, shown_title(&child.title), shown_title(&task.title) )); continue; } seen.insert(child.uuid.clone()); updates.push((child.uuid.clone(), task_status, stop_date)); successes.push(child); } }
updates.push((task.uuid.clone(), task_status, stop_date)); successes.push(task); }
let mut changes = BTreeMap::new(); for (uuid, status, stop_date) in updates { changes.insert( uuid.to_string(), WireObject::update( EntityType::Task7, TaskPatch { status: Some(status), stop_date: Some(stop_date), modification_date: Some(Some(now)), ..Default::default() }, ), ); }
(MarkCommitPlan { changes }, successes, errors)}
fn build_mark_checklist_plan( args: &MarkArgs, task: &crate::store::Task, checklist_raw: &str, now: f64,) -> std::result::Result<(MarkCommitPlan, Vec<crate::store::ChecklistItem>, String), String> { let (items, err) = resolve_checklist_items(task, checklist_raw); if !err.is_empty() { return Err(err); }
let (label, status): (&str, TaskStatus) = if args.check_ids.is_some() { ("checked", TaskStatus::Completed) } else if args.uncheck_ids.is_some() { ("unchecked", TaskStatus::Incomplete) } else { ("canceled", TaskStatus::Canceled) };
let mut changes = BTreeMap::new(); for item in &items { if matches!(item.status, TaskStatus::Unknown(_)) { return Err(format!( "Checklist item is of an unknown status: {}", one_line(&item.title) )); } if item.status == status { return Err(format!( "Checklist item is already {label}: {}", one_line(&item.title) )); } // a closed item carries the moment it closed // a to-do does too let stop_date = (status != TaskStatus::Incomplete).then_some(now); changes.insert( item.uuid.to_string(), WireObject::update( EntityType::ChecklistItem3, ChecklistItemPatch { status: Some(status), stop_date: Some(stop_date), modification_date: Some(now), ..Default::default() }, ), ); }
Ok((MarkCommitPlan { changes }, items, label.to_string()))}
impl Command for MarkArgs { fn run_with_ctx( &self, cli: &Cli, out: &mut dyn std::io::Write, ctx: &mut dyn crate::cmd_ctx::CmdCtx, ) -> Result<()> { let store = cli.load_store()?; let checklist_raw = self .check_ids .as_ref() .or(self.uncheck_ids.as_ref()) .or(self.check_cancel_ids.as_ref());
if let Some(checklist_raw) = checklist_raw { // ids that name one to-do count once let mut tasks: Vec<crate::store::Task> = Vec::new(); for identifier in &self.task_ids { let (task_opt, err, _) = store.resolve_mark_identifier(identifier.as_str()); let Some(task) = task_opt else { bail!("{err}"); }; if !tasks.iter().any(|seen| seen.uuid == task.uuid) { tasks.push(task); } } if tasks.len() != 1 { bail!( "Checklist flags (--check, --uncheck, --check-cancel) require exactly one to-do ID." ); } let task = tasks.remove(0);
if task.checklist_items.is_empty() { bail!("Item has no checklist: {}", shown_title(&task.title)); }
let (plan, items, label) = build_mark_checklist_plan(self, &task, checklist_raw, ctx.now_timestamp()) .map_err(anyhow::Error::msg)?;
ctx.commit_changes(plan.changes) .with_context(|| "Failed to mark checklist items")?;
let title = match label.as_str() { "checked" => format!("{} Checked", ICONS.checklist_done), "unchecked" => format!("{} Unchecked", ICONS.checklist_open), _ => format!("{} Canceled", ICONS.checklist_canceled), };
for item in items { writeln!( out, "{} {} {}", colored(&title, &[GREEN], cli.no_color()), one_line(&item.title), colored(&item.uuid, &[DIM], cli.no_color()) )?; } return Ok(()); }
let action = if self.done { "completed" } else if self.incomplete { "incomplete" } else { "canceled" };
let (plan, successes, errors) = build_mark_status_plan(self, &store, ctx.now_timestamp()); // every target is checked before any is written // a batch lands whole or not at all if !errors.is_empty() { bail!("{}", errors.join("\n")); }
ctx.commit_changes(plan.changes) .with_context(|| format!("Failed to mark items {action}"))?;
let label = match action { "completed" => format!("{} Completed", ICONS.done), "incomplete" => format!("{} Incomplete", ICONS.incomplete), _ => format!("{} Canceled", ICONS.canceled), }; for task in successes { writeln!( out, "{} {} {}", colored(&label, &[GREEN], cli.no_color()), shown_title(&task.title), colored(&task.uuid, &[DIM], cli.no_color()) )?; }
Ok(()) }}
#[cfg(test)]mod tests { use serde_json::json;
use super::*; use crate::{ ids::ThingsId, store::{ThingsStore, fold_items}, wire::{ checklist::ChecklistItemProps, recurrence::{RecurrenceRule, RecurrenceType}, task::{TaskProps, TaskStart, TaskStatus, TaskType}, }, };
const NOW: f64 = 1_700_000_111.0; const TASK_A: &str = "A7h5eCi24RvAWKC3Hv3muf"; const CHECK_A: &str = "MpkEei6ybkFS2n6SXvwfLf"; const CHECK_B: &str = "JFdhhhp37fpryAKu8UXwzK"; const TPL_A: &str = "MpkEei6ybkFS2n6SXvwfLf"; const TPL_B: &str = "JFdhhhp37fpryAKu8UXwzK";
fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore { let mut item = BTreeMap::new(); for (uuid, obj) in entries { item.insert(uuid, obj); } ThingsStore::from_raw_state(&fold_items([item])) }
fn task(uuid: &str, title: &str, status: i32) -> (String, WireObject) { task_for_entity(uuid, title, status, EntityType::Task6) }
fn task_for_entity( uuid: &str, title: &str, status: i32, entity: EntityType, ) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( entity, TaskProps { title: title.to_string(), item_type: TaskType::Todo, status: TaskStatus::from(status), start_location: TaskStart::Inbox, sort_index: 0, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn task_with_props( uuid: &str, title: &str, recurrence_rule: Option<RecurrenceRule>, recurrence_templates: Vec<&str>, ) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Task6, TaskProps { title: title.to_string(), item_type: TaskType::Todo, status: TaskStatus::Incomplete, start_location: TaskStart::Inbox, sort_index: 0, recurrence_rule, recurrence_template_ids: recurrence_templates .iter() .map(|t| { t.parse::<ThingsId>() .expect("test recurrence template id should parse") }) .collect(), creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn checklist(uuid: &str, task_uuid: &str, title: &str, ix: i32) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::ChecklistItem3, ChecklistItemProps { title: title.to_string(), task_ids: vec![ task_uuid .parse::<ThingsId>() .expect("test task id should parse as ThingsId"), ], status: TaskStatus::Incomplete, sort_index: ix, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
#[test] fn an_instance_whose_template_did_not_replay_is_refused() { // an object without a kind is held and read as no task let template: WireObject = serde_json::from_str(r#"{"t":0,"p":{"tt":"Water plants"}}"#).expect("template"); let store = build_store(vec![ (TPL_A.to_string(), template), task_with_props(TASK_A, "Water plants", None, vec![TPL_A]), ]); let task = store.get_task(TASK_A).expect("instance"); let (valid, message) = validate_recurring_instance(&task, &store); assert!(!valid); assert_eq!( message, "The template of this repeating item did not replay completely." ); }
#[test] fn mark_status_payloads() { let done_store = build_store(vec![task(TASK_A, "Alpha", 0)]); let (done_plan, _, errs) = build_mark_status_plan( &MarkArgs { task_ids: vec![TASK_A.parse().expect("id")], done: true, incomplete: false, canceled: false, check_ids: None, uncheck_ids: None, check_cancel_ids: None, }, &done_store, NOW, ); assert!(errs.is_empty()); assert_eq!( serde_json::to_value(done_plan.changes).expect("to value"), json!({ TASK_A: {"t":1,"e":"Task7","p":{"ss":3,"sp":NOW,"md":NOW}} }) );
let incomplete_store = build_store(vec![task(TASK_A, "Alpha", 3)]); let (incomplete_plan, _, _) = build_mark_status_plan( &MarkArgs { task_ids: vec![TASK_A.parse().expect("id")], done: false, incomplete: true, canceled: false, check_ids: None, uncheck_ids: None, check_cancel_ids: None, }, &incomplete_store, NOW, ); assert_eq!( serde_json::to_value(incomplete_plan.changes).expect("to value"), json!({ TASK_A: {"t":1,"e":"Task7","p":{"ss":0,"sp":null,"md":NOW}} }) ); }
#[test] fn mark_accepts_task7_and_rejects_opaque_repeaters() { let task7_store = build_store(vec![task_for_entity( TASK_A, "Current task", 0, EntityType::Task7, )]); let args = MarkArgs { task_ids: vec![TASK_A.parse().expect("id")], done: true, incomplete: false, canceled: false, check_ids: None, uncheck_ids: None, check_cancel_ids: None, }; let (plan, _, errors) = build_mark_status_plan(&args, &task7_store, NOW); assert!(errors.is_empty()); assert_eq!(plan.changes[TASK_A].entity_type, Some(EntityType::Task7));
let repeating = ( TASK_A.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Repeater".to_string(), repeater: Some(json!({"version": 1})), ..TaskProps::default() }, ), ); let (plan, _, errors) = build_mark_status_plan(&args, &build_store(vec![repeating]), NOW); assert!(plan.changes.is_empty()); assert_eq!(errors.len(), 1); assert!(errors[0].contains("an item with a repeater")); }
#[test] fn mark_checklist_payloads() { let store = build_store(vec![ task(TASK_A, "Task with checklist", 0), checklist(CHECK_A, TASK_A, "One", 1), checklist(CHECK_B, TASK_A, "Two", 2), ]); let task = store.get_task(TASK_A).expect("task");
let (checked_plan, _, _) = build_mark_checklist_plan( &MarkArgs { task_ids: vec![TASK_A.parse().expect("id")], done: false, incomplete: false, canceled: false, check_ids: Some(format!("{},{}", &CHECK_A[..6], &CHECK_B[..6])), uncheck_ids: None, check_cancel_ids: None, }, &task, &format!("{},{}", &CHECK_A[..6], &CHECK_B[..6]), NOW, ) .expect("checked plan"); assert_eq!( serde_json::to_value(checked_plan.changes).expect("to value"), json!({ CHECK_A: {"t":1,"e":"ChecklistItem3","p":{"ss":3,"sp":NOW,"md":NOW}}, CHECK_B: {"t":1,"e":"ChecklistItem3","p":{"ss":3,"sp":NOW,"md":NOW}} }) ); }
#[test] fn a_closed_project_takes_its_open_to_dos_along() { const PROJECT: &str = "Pj11111111111111111111"; const HEADING: &str = "Hd11111111111111111111"; const OPEN: &str = "Ae11111111111111111111"; const UNDER: &str = "Un11111111111111111111"; const DONE: &str = "Dn11111111111111111111"; let object = |title: &str, item_type: TaskType, status: TaskStatus, project: Option<&str>, heading: Option<&str>| { WireObject::create( EntityType::Task7, TaskProps { title: title.to_string(), item_type, status, parent_project_ids: project .map(|id| id.parse().expect("id")) .into_iter() .collect(), action_group_ids: heading .map(|id| id.parse().expect("id")) .into_iter() .collect(), ..Default::default() }, ) }; let store = |project: TaskStatus| { build_store(vec![ ( PROJECT.to_string(), object("Remodel", TaskType::Project, project, None, None), ), ( HEADING.to_string(), object( "Plumbing", TaskType::Heading, TaskStatus::Incomplete, Some(PROJECT), None, ), ), ( OPEN.to_string(), object( "Buy tiles", TaskType::Todo, TaskStatus::Incomplete, Some(PROJECT), None, ), ), ( UNDER.to_string(), object( "Order sink", TaskType::Todo, TaskStatus::Incomplete, None, Some(HEADING), ), ), ( DONE.to_string(), object( "Measure", TaskType::Todo, TaskStatus::Completed, Some(PROJECT), None, ), ), ]) }; let status = |done: bool, incomplete: bool| MarkArgs { task_ids: vec![PROJECT.parse().expect("id")], done, incomplete, canceled: !done && !incomplete, check_ids: None, uncheck_ids: None, check_cancel_ids: None, }; let (plan, _, errors) = build_mark_status_plan(&status(false, false), &store(TaskStatus::Incomplete), NOW); assert!(errors.is_empty(), "{errors:?}"); let mut keys = plan.changes.keys().cloned().collect::<Vec<_>>(); keys.sort(); let mut expected = vec![OPEN.to_string(), PROJECT.to_string(), UNDER.to_string()]; expected.sort(); assert_eq!(keys, expected); assert_eq!(plan.changes[UNDER].properties_map()["ss"], json!(2)); // reopening a project leaves its to-dos as they are // the app does the same let (plan, _, errors) = build_mark_status_plan(&status(false, true), &store(TaskStatus::Completed), NOW); assert!(errors.is_empty(), "{errors:?}"); assert_eq!(plan.changes.keys().collect::<Vec<_>>(), vec![PROJECT]); }
#[test] fn mark_recurring_rejection_cases() { let store = build_store(vec![task_with_props( TASK_A, "Recurring template", Some(RecurrenceRule { recurrence_type: RecurrenceType::FixedSchedule, ..Default::default() }), vec![], )]); let status = |done: bool| MarkArgs { task_ids: vec![TASK_A.parse().expect("id")], done, incomplete: false, canceled: !done, check_ids: None, uncheck_ids: None, check_cancel_ids: None, }; // a template takes no status, canceled no more than completed for done in [true, false] { let (plan, _, errs) = build_mark_status_plan(&status(done), &store, NOW); assert!(plan.changes.is_empty()); assert_eq!( errs, vec![ "A repeat template takes no status, mark one of its instances instead. (Recurring template)" ] ); }
// both templates exist // an instance of two is malformed let template = |uuid| { task_with_props( uuid, "Recurring template", Some(RecurrenceRule { recurrence_type: RecurrenceType::FixedSchedule, ..Default::default() }), vec![], ) }; let store = build_store(vec![ template(TPL_A), template(TPL_B), task_with_props(TASK_A, "Recurring instance", None, vec![TPL_A, TPL_B]), ]); for done in [true, false] { let (_, _, errs) = build_mark_status_plan(&status(done), &store, NOW); assert_eq!( errs, vec![ "This repeating item names 2 templates where one is expected. (Recurring instance)" ] ); } }}