Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916use std::{ collections::{BTreeMap, HashMap, HashSet}, str::FromStr,};
use anyhow::{Context as _, Result};use clap::Args;use serde_json::json;
use crate::{ app::Cli, arg_types::IdentifierToken, commands::{Command, TagDeltaArgs}, common::{ Container, DIM, GREEN, ICONS, colored, container_refusal, day_of, day_timestamp, one_line, parse_day, parse_instant, parse_reminder, resolve_container, resolve_removable_tag_ids, resolve_tag_ids, shown_title, task6_note, }, ids::ThingsId, ordering::allocate, repeat::{Bound, ChecklistCopy, RepeatSpec, TemplateSource, bound, checklist_items, template}, store::Task, wire::{ checklist::{ChecklistItemPatch, ChecklistItemProps}, notes::{StructuredTaskNotes, TaskNotes}, task::{TaskPatch, TaskStart, TaskStatus}, wire_object::{EntityType, Properties, WireObject}, },};
#[derive(Args)]#[command( about = "Edit the title, notes, container, tags, checklist, when, deadline, reminder, or repeat of a to-do")]pub struct EditArgs { #[arg(required = true, help = "To-do ID(s) (or unique ID prefixes)")] pub task_ids: Vec<IdentifierToken>, #[arg(long, short = 't', help = "Replace title (single to-do only)")] pub title: Option<String>, #[arg( long, short = 'n', help = "Replace notes (single to-do only, an empty string clears them)" )] pub notes: Option<String>, #[arg( long = "move", short = 'm', help = "Move to Inbox, clear, project ID or prefix, or area ID or prefix" )] pub move_target: Option<IdentifierToken>, #[command(flatten)] pub tag_delta: TagDeltaArgs, #[arg( long = "add-checklist", short = 'c', value_name = "TITLE", help = "Add a checklist item (repeatable, single to-do only)" )] pub add_checklist: Vec<String>, #[arg( long = "remove-checklist", short = 'x', value_name = "IDS", help = "Remove checklist items by comma-separated ID prefixes (single to-do only)" )] pub remove_checklist: Option<String>, #[arg( long = "rename-checklist", short = 'k', value_name = "ID:TITLE", help = "Rename a checklist item: ID-prefix:new title (repeatable, single to-do only)" )] pub rename_checklist: Vec<String>, #[arg( long = "completed-on", value_name = "DATETIME", help = "Set when a completed or canceled to-do was closed (single to-do only, RFC 3339 or YYYY-MM-DD at local midnight)" )] pub completed_on: Option<String>, #[arg( long = "created-on", value_name = "DATETIME", help = "Set when the to-do was created (single to-do only, RFC 3339 or YYYY-MM-DD at local midnight)" )] pub created_on: Option<String>, #[arg( long, short = 'w', help = "When: anytime, someday, today, evening, or YYYY-MM-DD" )] pub when: Option<String>, #[arg( long = "deadline", short = 'd', conflicts_with = "clear_deadline", help = "Deadline date (YYYY-MM-DD)" )] pub deadline_date: Option<String>, #[arg(long = "clear-deadline", short = 'D', help = "Clear deadline")] pub clear_deadline: bool, #[arg( long = "reminder", value_name = "HH:MM", conflicts_with = "clear_reminder", help = "Reminder time on the scheduled day (HH:MM)" )] pub reminder: Option<String>, #[arg(long = "clear-reminder", help = "Clear reminder")] pub clear_reminder: bool, #[arg( long = "repeat", value_name = "RULE", help = "Repeat: daily, weekly[:mon,thu], monthly[:15|last], yearly[:MM-DD] or after:2w, with /N for every N (single to-do only)" )] pub repeat: Option<String>, #[arg( long = "times", value_name = "N", requires = "repeat", conflicts_with = "until", help = "End the repeat after N times" )] pub times: Option<i32>, #[arg( long = "until", value_name = "YYYY-MM-DD", requires = "repeat", help = "End the repeat on a day" )] pub until: 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())}
#[derive(Debug)]struct EditPlan { tasks: Vec<crate::store::Task>, changes: BTreeMap<String, WireObject>, labels: Vec<String>,}
/// the transition to an undated to-do////// it clears the day, the place in Today, the evening flag and the reminder, whether a move to the Inbox or a when of anytime or someday brought it aboutfn unschedule(update: &mut TaskPatch, task: &Task) { update.scheduled_date = Some(None); update.today_index_reference = Some(None); update.evening_bit = Some(0); if task.alarm_time_offset.is_some() { update.alarm_time_offset = Some(None); }}
/// when, deadline, reminder and repeat, the fields the app's popovers edit////// `checklist` is the to-do's checklist as this edit leaves it/// a new template copies it#[allow(clippy::too_many_arguments)]fn apply_schedule( args: &EditArgs, store: &crate::store::ThingsStore, task: &Task, update: &mut TaskPatch, changes: &mut BTreeMap<String, WireObject>, labels: &mut Vec<String>, checklist: &[ChecklistCopy], now: f64, today_ts: i64, next_id: &mut dyn FnMut() -> String,) -> std::result::Result<(), String> { let scheduling = args.when.is_some() || args.deadline_date.is_some() || args.clear_deadline || args.reminder.is_some() || args.clear_reminder || args.repeat.is_some(); if !scheduling { if args.times.is_some() || args.until.is_some() { return Err("--times and --until need --repeat".to_string()); } return Ok(()); } if task.has_repeater() { return Err( "Cannot reschedule a to-do with a repeater, whose bookkeeping the Apple clients keep." .to_string(), ); } let mut label = |text: String| { if !labels.contains(&text) { labels.push(text); } };
if let Some(when_raw) = &args.when { let when = when_raw.trim(); let when_l = when.to_lowercase(); if when_l == "anytime" || when_l == "someday" { update.start_location = Some(if when_l == "anytime" { TaskStart::Anytime } else { TaskStart::Someday }); unschedule(update, task); label(format!("when={when_l}")); } else if when_l == "today" || when_l == "evening" { update.start_location = Some(TaskStart::Anytime); update.scheduled_date = Some(Some(today_ts)); update.today_index_reference = Some(Some(today_ts)); update.evening_bit = Some(if when_l == "evening" { 1 } else { 0 }); label(format!("when={when_l}")); } else { let when_day = parse_day(when, "--when").map_err(|_| { format!( "Invalid --when: {} (expected anytime, someday, today, evening or YYYY-MM-DD)", one_line(when) ) })?; let day_ts = day_timestamp(when_day); update.start_location = Some(if day_ts <= today_ts { TaskStart::Anytime } else { TaskStart::Someday }); update.scheduled_date = Some(Some(day_ts)); update.today_index_reference = Some(Some(day_ts)); update.evening_bit = Some(0); label(format!("when={when}")); } }
if let Some(deadline) = &args.deadline_date { let day = parse_day(deadline, "--deadline")?; update.deadline = Some(Some(day_timestamp(day) as f64)); label(format!("deadline={deadline}")); } if args.clear_deadline { update.deadline = Some(None); label("deadline=none".to_string()); }
if let Some(reminder) = &args.reminder { let dated = match update.scheduled_date { Some(day) => day.is_some(), None => task.start_date.is_some(), }; // a template's reminder goes to its instances // the instances carry the day if !dated && !task.is_recurrence_template() { return Err( "--reminder requires a scheduled day, set --when today or YYYY-MM-DD".to_string(), ); } update.alarm_time_offset = Some(Some(parse_reminder(reminder)?)); label(format!("reminder={reminder}")); } if args.clear_reminder { update.alarm_time_offset = Some(None); label("reminder=none".to_string()); }
if let Some(rule_text) = &args.repeat { if task.is_recurrence_template() || task.is_recurrence_instance() { return Err("This to-do already repeats.".to_string()); } // the template stays in the to-do's project and heading unless the edit moves it // the pass makes nothing in a closed, trashed or partly replayed container, nor in a repeat template if update.parent_project_ids.is_none() && update.area_ids.is_none() { for id in task .action_group .iter() .cloned() .chain(store.effective_project_uuid(task)) { let Some(container) = store.tasks_by_uuid.get(&id) else { return Err(format!("Container not found: {id}")); }; if let Some(refusal) = container_refusal(store, container) { return Err(refusal); } } } // nor in an area or with a tag that did not replay completely let area = match &update.area_ids { Some(ids) => ids.first().cloned(), None => task.area.clone(), }; if let Some(id) = area { let Some(area) = store.areas_by_uuid.get(&id) else { return Err(format!("Container not found: {id}")); }; if area.degraded { return Err(format!( "Container did not replay completely: {}", shown_title(&area.title) )); } } if let Some(tag) = update .tag_ids .as_ref() .unwrap_or(&task.tags) .iter() .filter_map(|id| store.tags_by_uuid.get(id)) .find(|tag| tag.degraded) { return Err(format!( "Tag did not replay completely: {}", one_line(&store.resolve_tag_title(&tag.uuid)) )); } let spec: RepeatSpec = rule_text.parse()?; let bound = bound(args.times, args.until.as_deref())?; let when_day = match update.scheduled_date { Some(Some(day)) => day_of(day), Some(None) => None, None => task.start_date.map(|day| day.date_naive()), }; let Some(when_day) = when_day else { return Err( "--repeat requires a scheduled day, set --when today or YYYY-MM-DD".to_string(), ); }; let first = spec.first_occurrence(when_day); if first != when_day { return Err(format!( "{when_day} is not a day of {rule_text}, the next one is {first}, set --when {first}" )); } if first < day_of(today_ts).expect("today") { return Err(format!( "A repeat cannot start on {first}, which has passed, set --when today or a later day" )); } if let Bound::Until(until) = bound && until < first { return Err(format!( "--until {until} is before the first occurrence {first}" )); } let deadline = match update.deadline { Some(deadline) => deadline.is_some(), None => task.deadline.is_some(), }; if deadline { return Err( "A repeating to-do keeps its deadline as an offset the CLI does not write yet, clear the deadline before adding --repeat." .to_string(), ); } let rule = spec.rule(first, day_of(today_ts).expect("today"), bound); label(format!( "repeat={}", rule.human_readable().unwrap_or_else(|_| rule_text.clone()) )); // the template mirrors the to-do as this edit leaves it, not as it was let source = TemplateSource { title: update.title.clone().unwrap_or_else(|| task.title.clone()), notes: update .notes .clone() .or_else(|| task.notes.as_deref().map(task6_note)), tag_ids: update.tag_ids.clone().unwrap_or_else(|| task.tags.clone()), parent_project_ids: update .parent_project_ids .clone() .unwrap_or_else(|| task.project.iter().cloned().collect()), area_ids: update .area_ids .clone() .unwrap_or_else(|| task.area.iter().cloned().collect()), action_group_ids: update .action_group_ids .clone() .unwrap_or_else(|| task.action_group.iter().cloned().collect()), alarm_time_offset: match update.alarm_time_offset { Some(alarm) => alarm, None => task.alarm_time_offset, }, sort_index: task.index, today_sort_index: task.today_index, conflict_overrides: Some(json!({"_t": "oo", "sn": {}})), }; let template_uuid = next_id(); let template_id = ThingsId::from_str(&template_uuid).map_err(|e| e.to_string())?; update.recurrence_template_ids = Some(vec![template_id.clone()]); let template = template(&spec, rule, first, source, now) .ok_or_else(|| format!("The day after {first} is out of range"))?; changes.insert( template_uuid, WireObject::create(EntityType::Task7, template), ); changes.extend(checklist_items(checklist, &template_id, now, next_id)); } else if args.times.is_some() || args.until.is_some() { return Err("--times and --until need --repeat".to_string()); } Ok(())}
impl Command for EditArgs { 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 now = ctx.now_timestamp(); let today = ctx.today_timestamp(); let mut id_gen = || ctx.next_id(); let plan = build_edit_plan(self, &store, now, today, &mut id_gen).map_err(anyhow::Error::msg)?;
ctx.commit_changes(plan.changes.clone()) .with_context(|| "Failed to edit item")?;
let label_str = colored( format!("({})", plan.labels.join(", ")), &[DIM], cli.no_color(), ); for task in plan.tasks { let title_display = plan .changes .get(&task.uuid.to_string()) .and_then(|obj| obj.properties_map().get("tt").cloned()) .and_then(|v| v.as_str().map(ToString::to_string)) .unwrap_or(task.title); writeln!( out, "{} {} {} {}", colored(format!("{} Edited", ICONS.done), &[GREEN], cli.no_color()), shown_title(&title_display), colored(&task.uuid, &[DIM], cli.no_color()), label_str )?; }
Ok(()) }}
fn build_edit_plan( args: &EditArgs, store: &crate::store::ThingsStore, now: f64, today_ts: i64, next_id: &mut dyn FnMut() -> String,) -> std::result::Result<EditPlan, String> { let mut tasks = 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 { return Err(err); }; if task.is_project() { return Err("Use 'projects edit' to edit a project.".to_string()); } // ids that name one to-do count once if !tasks.iter().any(|seen: &Task| seen.uuid == task.uuid) { tasks.push(task); } }
let multiple = tasks.len() > 1; if multiple && args.title.is_some() { return Err("--title requires a single to-do ID.".to_string()); } if multiple && args.notes.is_some() { return Err("--notes requires a single to-do ID.".to_string()); } if multiple && (args.completed_on.is_some() || args.created_on.is_some()) { return Err("--completed-on/--created-on require a single to-do ID.".to_string()); } if multiple && args.repeat.is_some() { return Err("--repeat requires a single to-do ID.".to_string()); } if multiple && (!args.add_checklist.is_empty() || args.remove_checklist.is_some() || !args.rename_checklist.is_empty()) { return Err( "--add-checklist/--remove-checklist/--rename-checklist require a single to-do ID." .to_string(), ); }
let mut shared_update = TaskPatch::default(); let mut move_from_inbox_st: Option<TaskStart> = None; let mut labels: Vec<String> = Vec::new(); let move_l = args .move_target .as_deref() .map(str::to_lowercase) .unwrap_or_default(); if let Some(move_raw) = args.move_target.as_deref() { if move_l == "inbox" { if args.when.is_some() || args.reminder.is_some() || args.repeat.is_some() { return Err( "--move inbox cannot be combined with --when, --reminder or --repeat, an Inbox to-do has no day." .to_string(), ); } shared_update.parent_project_ids = Some(vec![]); shared_update.area_ids = Some(vec![]); shared_update.action_group_ids = Some(vec![]); shared_update.start_location = Some(TaskStart::Inbox); labels.push("move=inbox".to_string()); } else if move_l == "clear" { labels.push("move=clear".to_string()); } else { match resolve_container(store, move_raw, "--move")? { Container::Project(project_uuid) => { shared_update.parent_project_ids = Some(vec![project_uuid]); shared_update.area_ids = Some(vec![]); } Container::Area(area_uuid) => { shared_update.area_ids = Some(vec![area_uuid]); shared_update.parent_project_ids = Some(vec![]); } } shared_update.action_group_ids = Some(vec![]); move_from_inbox_st = Some(TaskStart::Anytime); labels.push(format!("move={move_raw}")); } }
let mut add_tag_ids = Vec::new(); let mut remove_tag_ids = Vec::new(); if let Some(raw) = &args.tag_delta.add_tags { let (ids, err) = resolve_tag_ids(store, raw); if !err.is_empty() { return Err(err); } add_tag_ids = ids; labels.push("add-tags".to_string()); } if let Some(raw) = &args.tag_delta.remove_tags { let (ids, err) = resolve_removable_tag_ids(store, raw); if !err.is_empty() { return Err(err); } remove_tag_ids = ids; if !labels.iter().any(|l| l == "remove-tags") { labels.push("remove-tags".to_string()); } }
let mut renames: Vec<(String, String)> = Vec::new(); for token in &args.rename_checklist { let Some((short_id, new_title)) = token.split_once(':') else { return Err(format!( "--rename-checklist requires 'ID-prefix:new title' format, got: {token:?}" )); }; let short_id = short_id.trim(); let new_title = new_title.trim(); if short_id.is_empty() || new_title.is_empty() { return Err(format!( "--rename-checklist requires 'ID-prefix:new title' format, got: {token:?}" )); } renames.push((short_id.to_string(), new_title.to_string())); }
let mut changes: BTreeMap<String, WireObject> = BTreeMap::new();
for task in &tasks { let mut update = shared_update.clone();
// the instances carry the schedule // the template keeps the rule's bookkeeping if task.is_recurrence_template() && (args.when.is_some() || args.deadline_date.is_some() || args.clear_deadline || move_l == "inbox") { return Err(format!( "Item is a repeat template and takes no --when, --deadline or --move inbox, which belong to its instances: {}", shown_title(&task.title) )); }
if let Some(title) = &args.title { let title = title.trim(); if title.is_empty() { return Err("To-do title cannot be empty.".to_string()); } update.title = Some(title.to_string()); if !labels.iter().any(|l| l == "title") { labels.push("title".to_string()); } }
if let Some(completed_on) = &args.completed_on { if !task.is_completed() && !task.is_canceled() { return Err("--completed-on needs a completed or canceled to-do.".to_string()); } update.stop_date = Some(Some(parse_instant(completed_on, "--completed-on")?)); labels.push("completed-on".to_string()); } if let Some(created_on) = &args.created_on { update.creation_date = Some(Some(parse_instant(created_on, "--created-on")?)); labels.push("created-on".to_string()); }
if let Some(notes) = &args.notes { if notes.is_empty() { update.notes = Some(TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 1, ch: Some(0), v: Some(String::new()), ps: Vec::new(), unknown_fields: Default::default(), })); } else { update.notes = Some(task6_note(notes)); } if !labels.iter().any(|l| l == "notes") { labels.push("notes".to_string()); } }
if move_l == "clear" { update.parent_project_ids = Some(vec![]); update.area_ids = Some(vec![]); update.action_group_ids = Some(vec![]); if task.start == TaskStart::Inbox { update.start_location = Some(TaskStart::Anytime); } } if move_l == "inbox" { unschedule(&mut update, task); }
if let Some(move_from_inbox_st) = move_from_inbox_st && task.start == TaskStart::Inbox { update.start_location = Some(move_from_inbox_st); }
if !add_tag_ids.is_empty() || !remove_tag_ids.is_empty() { let mut current = task.tags.clone(); for uuid in &add_tag_ids { if !current.iter().any(|c| c == uuid) { current.push(uuid.clone()); } } current.retain(|uuid| !remove_tag_ids.iter().any(|r| r == uuid)); update.tag_ids = Some(current); }
// the checklist as this edit leaves it // kept alongside the changes for a template to copy let mut checklist = task .checklist_items .iter() .map(|item| (item.uuid.clone(), item.title.clone(), item.index)) .collect::<Vec<_>>();
let mut removed = HashSet::new(); if let Some(remove_raw) = &args.remove_checklist { let (items, err) = resolve_checklist_items(task, remove_raw); if !err.is_empty() { return Err(err); } removed = items.into_iter().map(|i| i.uuid).collect::<HashSet<_>>(); for uuid in &removed { changes.insert( uuid.to_string(), WireObject::delete(EntityType::ChecklistItem3), ); } checklist.retain(|(uuid, _, _)| !removed.contains(uuid)); if !labels.iter().any(|l| l == "remove-checklist") { labels.push("remove-checklist".to_string()); } }
if !renames.is_empty() { // the renames apply in the order given // a second rename of one item is refused rather than one of the two winning let mut renamed = HashSet::new(); for (short_id, new_title) in &renames { let matches = task .checklist_items .iter() .filter(|i| i.uuid.starts_with(short_id)) .cloned() .collect::<Vec<_>>(); if matches.is_empty() { return Err(format!("Checklist item not found: '{short_id}'")); } if matches.len() > 1 { return Err(format!( "Ambiguous checklist item prefix '{short_id}' ({} matches).", matches.len() )); } if removed.contains(&matches[0].uuid) { return Err(format!( "Checklist item '{short_id}' cannot be removed and renamed in one edit." )); } if !renamed.insert(matches[0].uuid.clone()) { return Err(format!( "Checklist item '{short_id}' cannot be renamed twice in one edit." )); } changes.insert( matches[0].uuid.to_string(), WireObject::update( EntityType::ChecklistItem3, ChecklistItemPatch { title: Some(new_title.to_string()), modification_date: Some(now), ..Default::default() }, ), ); for (uuid, title, _) in checklist.iter_mut() { if *uuid == matches[0].uuid { *title = new_title.to_string(); } } } if !labels.iter().any(|l| l == "rename-checklist") { labels.push("rename-checklist".to_string()); } }
if !args.add_checklist.is_empty() { // the new items take the slots after the last one // the items already there move only when the run has to be rebalanced let mut run: Vec<(ThingsId, i32)> = checklist .iter() .map(|(uuid, _, index)| (uuid.clone(), *index)) .collect(); run.sort_by_key(|(_, index)| *index); let before: HashMap<ThingsId, i32> = run.iter().cloned().collect(); let mut added: HashMap<ThingsId, String> = HashMap::new(); for title in &args.add_checklist { let title = title.trim(); if title.is_empty() { return Err("Checklist item title cannot be empty.".to_string()); } let uuid = ThingsId::from_str(&next_id()).map_err(|e| e.to_string())?; let (index, moved) = allocate(&run, run.len()); for (moved_uuid, slot) in moved { if let Some(member) = run.iter_mut().find(|(id, _)| *id == moved_uuid) { member.1 = slot; } } run.push((uuid.clone(), index)); added.insert(uuid, title.to_string()); } for (uuid, index) in run { if let Some(title) = added.remove(&uuid) { changes.insert( uuid.to_string(), WireObject::create( EntityType::ChecklistItem3, ChecklistItemProps { title: title.clone(), task_ids: vec![task.uuid.clone()], status: TaskStatus::Incomplete, sort_index: index, creation_date: Some(now), modification_date: Some(now), ..Default::default() }, ), ); checklist.push((uuid, title, index)); } else if before.get(&uuid) != Some(&index) { // a rename in the same command already holds a patch for the item // the slot joins it match changes.get_mut(&uuid.to_string()) { Some(WireObject { payload: Properties::ChecklistUpdate(patch), .. }) => { patch.sort_index = Some(index); patch.modification_date = Some(now); } _ => { changes.insert( uuid.to_string(), WireObject::update( EntityType::ChecklistItem3, ChecklistItemPatch { sort_index: Some(index), modification_date: Some(now), ..Default::default() }, ), ); } } if let Some(member) = checklist.iter_mut().find(|(id, _, _)| *id == uuid) { member.2 = index; } } } if !labels.iter().any(|l| l == "add-checklist") { labels.push("add-checklist".to_string()); } }
let checklist = checklist .into_iter() .map(|(_, title, index)| ChecklistCopy { title, index }) .collect::<Vec<_>>(); apply_schedule( args, store, task, &mut update, &mut changes, &mut labels, &checklist, now, today_ts, next_id, )?;
let has_checklist_changes = !args.add_checklist.is_empty() || args.remove_checklist.is_some() || !renames.is_empty(); if update.is_empty() && !has_checklist_changes { return Err("No edit changes requested.".to_string()); }
if !update.is_empty() { update.modification_date = Some(Some(now)); changes.insert( task.uuid.to_string(), WireObject::update(EntityType::Task7, update), ); } }
Ok(EditPlan { tasks, changes, labels, })}
#[cfg(test)]mod tests { use std::collections::BTreeMap;
use serde_json::json;
use super::*; use crate::{ ids::ThingsId, store::{ThingsStore, fold_items}, wire::{ area::AreaProps, checklist::ChecklistItemProps, tags::TagProps, task::{TaskProps, TaskStart, TaskStatus, TaskType}, wire_object::{EntityType, OperationType, WireItem, WireObject}, }, };
const NOW: f64 = 1_700_000_222.0; const TODAY: i64 = 1_699_920_000; const TASK_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf"; const TASK_UUID2: &str = "3H9jsMx3kYMrQ4M7DReSRn"; const PROJECT_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V"; const AREA_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf"; const CHECK_A: &str = "5uwoHPi5m5i8QJa6Rae6Cn"; const CHECK_B: &str = "CwhFwmHxjHkR7AFn9aJH9Q";
fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore { let mut item: WireItem = BTreeMap::new(); for (uuid, obj) in entries { item.insert(uuid, obj); } let raw = fold_items([item]); ThingsStore::from_raw_state(&raw) }
fn task(uuid: &str, title: &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, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn task_with(uuid: &str, title: &str, tag_ids: 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, tag_ids: tag_ids .iter() .map(|t| { t.parse::<ThingsId>() .expect("test tag id should parse as ThingsId") }) .collect(), creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn project(uuid: &str, title: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Task6, TaskProps { title: title.to_string(), item_type: TaskType::Project, status: TaskStatus::Incomplete, start_location: TaskStart::Anytime, sort_index: 0, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn area(uuid: &str, title: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Area3, AreaProps { title: title.to_string(), sort_index: 0, ..Default::default() }, ), ) }
fn tag(uuid: &str, title: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Tag4, TagProps { title: title.to_string(), sort_index: 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() }, ), ) }
fn assert_task_update(plan: &EditPlan, uuid: &str) -> BTreeMap<String, serde_json::Value> { let obj = plan.changes.get(uuid).expect("missing task change"); assert_eq!(obj.operation_type, OperationType::Update); assert_eq!(obj.entity_type, Some(EntityType::Task7)); obj.properties_map() }
#[test] fn edit_title_and_notes_payloads() { let store = build_store(vec![task(TASK_UUID, "Old title")]); let args = EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: Some("New title".to_string()), notes: Some("new notes".to_string()), move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }; let mut id_gen = || "X".to_string(); let plan = build_edit_plan(&args, &store, NOW, TODAY, &mut id_gen).expect("plan"); let p = assert_task_update(&plan, TASK_UUID); assert_eq!(p.get("tt"), Some(&json!("New title"))); assert_eq!(p.get("md"), Some(&json!(NOW))); assert!(p.contains_key("nt")); }
#[test] fn edit_move_targets_payload() { let store = build_store(vec![ task(TASK_UUID, "Movable"), project(PROJECT_UUID, "Roadmap"), area(AREA_UUID, "Work"), ]);
let mut id_gen = || "X".to_string(); let inbox = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: Some("inbox".parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect("inbox plan"); let p = assert_task_update(&inbox, TASK_UUID); assert_eq!(p.get("st"), Some(&json!(0))); assert_eq!(p.get("pr"), Some(&json!([]))); assert_eq!(p.get("ar"), Some(&json!([])));
let clear = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: Some("clear".parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect("clear plan"); let p = assert_task_update(&clear, TASK_UUID); assert_eq!(p.get("st"), Some(&json!(1)));
let project_move = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: Some(PROJECT_UUID.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect("project move plan"); let p = assert_task_update(&project_move, TASK_UUID); assert_eq!(p.get("pr"), Some(&json!([PROJECT_UUID]))); assert_eq!(p.get("st"), Some(&json!(1))); }
#[test] fn a_repeat_added_by_an_edit_mirrors_the_edited_to_do() { const TAG: &str = "Bt11111111111111111111"; const AREA: &str = "Ba11111111111111111111"; let store = build_store(vec![ task(TASK_UUID, "Old title"), checklist(CHECK_A, TASK_UUID, "Step one", 1), checklist(CHECK_B, TASK_UUID, "Step two", 2), tag(TAG, "Work"), area(AREA, "Home"), ]); let args = EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: Some("New title".to_string()), notes: Some("New notes".to_string()), move_target: Some(AREA.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: Some("Work".to_string()), remove_tags: None, }, add_checklist: vec!["Step three".to_string()], remove_checklist: Some(CHECK_B[..6].to_string()), rename_checklist: vec![format!("{}:Step won", &CHECK_A[..6])], completed_on: None, created_on: None, when: Some("today".to_string()), deadline_date: None, clear_deadline: false, reminder: Some("09:30".to_string()), clear_reminder: false, repeat: Some("daily".to_string()), times: None, until: None, }; let id = |n: u128| ThingsId::from_u128(n).to_string(); let mut ids = (1..).map(id); let mut id_gen = || ids.next().expect("id"); let plan = build_edit_plan(&args, &store, NOW, TODAY, &mut id_gen).expect("plan");
// the first id is the added item on the to-do // the second is the template // the third and fourth are its checklist copies let template = plan.changes.get(&id(2)).expect("template").properties_map(); assert_eq!(template.get("tt"), Some(&json!("New title"))); assert_eq!(template["nt"]["v"], json!("New notes")); assert!(template.get("rr").is_some_and(|rule| !rule.is_null())); // the tags, the container and the reminder are the edited to-do's let edited = plan .changes .get(TASK_UUID) .expect("the to-do") .properties_map(); assert_eq!(template.get("tg"), Some(&json!([TAG]))); assert_eq!(template.get("ar"), Some(&json!([AREA]))); assert!(template.get("ato").is_some_and(|offset| !offset.is_null())); for key in ["tg", "ar", "ato"] { assert_eq!(template.get(key), edited.get(key), "{key}"); } let copies = [3, 4] .iter() .map(|n| plan.changes.get(&id(*n)).expect("copy").properties_map()) .collect::<Vec<_>>(); assert_eq!(copies[0].get("tt"), Some(&json!("Step won"))); assert_eq!(copies[0].get("ts"), Some(&json!([id(2)]))); assert_eq!(copies[1].get("tt"), Some(&json!("Step three"))); // the removed item's slot is free again // the added item takes it assert_eq!(copies[1].get("ix"), Some(&json!(2))); assert!(!plan.changes.contains_key(&id(5)));
// a deadline has no place on the template yet // the edit is refused let dated = EditArgs { deadline_date: Some("2027-01-15".to_string()), ..args }; let err = build_edit_plan(&dated, &store, NOW, TODAY, &mut id_gen).expect_err("deadline"); assert!(err.contains("clear the deadline")); }
#[test] fn a_checklist_at_the_index_limit_is_respaced_before_the_new_item() { let store = build_store(vec![ task(TASK_UUID, "Bounded"), checklist(CHECK_A, TASK_UUID, "Step one", i32::MAX), ]); let args = EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec!["Step two".to_string()], remove_checklist: None, rename_checklist: vec![format!("{}:Step won", &CHECK_A[..6])], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }; let mut ids = (1..).map(|n: u128| ThingsId::from_u128(n).to_string()); let mut id_gen = || ids.next().expect("id"); let plan = build_edit_plan(&args, &store, NOW, TODAY, &mut id_gen).expect("plan");
// the item at the limit moves to the first slot of the rebalanced run and keeps its rename // the new item follows let moved = plan .changes .get(CHECK_A) .expect("the item moves") .properties_map(); assert_eq!(moved.get("ix"), Some(&json!(1024))); assert_eq!(moved.get("tt"), Some(&json!("Step won"))); let added = plan .changes .get(&ThingsId::from_u128(1).to_string()) .expect("the new item") .properties_map(); assert_eq!(added.get("ix"), Some(&json!(2048))); }
#[test] fn moving_to_the_inbox_clears_the_day_and_the_reminder() { let scheduled = ( TASK_UUID.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Dated".to_string(), start_location: TaskStart::Anytime, scheduled_date: Some(TODAY), today_index_reference: Some(TODAY), evening_bit: 1, alarm_time_offset: Some(32400), creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ); let store = build_store(vec![scheduled]); let args = |move_target: Option<&str>, when: Option<&str>| EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: move_target.map(|target| target.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: when.map(str::to_string), deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }; let mut id_gen = || "X".to_string(); for (move_target, when) in [(Some("inbox"), None), (None, Some("someday"))] { let plan = build_edit_plan(&args(move_target, when), &store, NOW, TODAY, &mut id_gen) .expect("plan"); let p = assert_task_update(&plan, TASK_UUID); assert_eq!(p.get("sr"), Some(&json!(null))); assert_eq!(p.get("tir"), Some(&json!(null))); assert_eq!(p.get("sb"), Some(&json!(0))); assert_eq!(p.get("ato"), Some(&json!(null))); } let err = build_edit_plan( &args(Some("inbox"), Some("today")), &store, NOW, TODAY, &mut id_gen, ) .expect_err("inbox and a day"); assert!(err.starts_with("--move inbox cannot be combined")); }
#[test] fn a_template_keeps_its_schedule_and_takes_a_reminder() { let template = ( TASK_UUID.to_string(), WireObject::create( EntityType::Task7, TaskProps { title: "Water plants".to_string(), start_location: TaskStart::Someday, recurrence_rule: Some( serde_json::from_value(json!({"ed":64092211200_i64,"fa":1,"fu":16,"ia":TODAY,"of":[{"dy":0}],"rc":0,"rrv":4,"sr":TODAY,"tp":0,"ts":0})) .expect("rule"), ), instance_creation_start_date: Some(TODAY + 86_400), today_index_reference: Some(TODAY + 86_400), instance_creation_count: 1, creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ); let store = build_store(vec![template]); let args = |move_target: Option<&str>, when: Option<&str>, reminder: Option<&str>| EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: move_target.map(|target| target.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: when.map(str::to_string), deadline_date: None, clear_deadline: false, reminder: reminder.map(str::to_string), clear_reminder: false, repeat: None, times: None, until: None, }; let mut id_gen = || "X".to_string(); for (move_target, when) in [(None, Some("2023-11-20")), (Some("inbox"), None)] { let err = build_edit_plan( &args(move_target, when, None), &store, NOW, TODAY, &mut id_gen, ) .expect_err("a template has no day"); assert!(err.contains("is a repeat template"), "{err}"); } let plan = build_edit_plan( &args(None, None, Some("08:00")), &store, NOW, TODAY, &mut id_gen, ) .expect("reminder"); let p = assert_task_update(&plan, TASK_UUID); assert_eq!(p.get("ato"), Some(&json!(28800))); assert!(!p.contains_key("sr") && !p.contains_key("st")); }
#[test] fn edit_multi_id_move_and_rejections() { let store = build_store(vec![ task(TASK_UUID, "Task One"), task(TASK_UUID2, "Task Two"), project(PROJECT_UUID, "Roadmap"), ]);
let mut id_gen = || "X".to_string(); let plan = build_edit_plan( &EditArgs { task_ids: vec![ TASK_UUID.parse().expect("id"), TASK_UUID2.parse().expect("id"), ], title: None, notes: None, move_target: Some(PROJECT_UUID.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect("multi move"); assert_eq!(plan.changes.len(), 2);
let err = build_edit_plan( &EditArgs { task_ids: vec![ TASK_UUID.parse().expect("id"), TASK_UUID2.parse().expect("id"), ], title: Some("New".to_string()), notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("title should reject"); assert_eq!(err, "--title requires a single to-do ID."); }
#[test] fn edit_tag_payloads() { let tag1 = "WukwpDdL5Z88nX3okGMKTC"; let tag2 = "JiqwiDaS3CAyjCmHihBDnB"; let store = build_store(vec![ task_with(TASK_UUID, "A", vec![tag1]), tag(tag1, "Work"), tag(tag2, "Focus"), ]);
let mut id_gen = || "X".to_string(); let plan = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: Some("Focus".to_string()), remove_tags: Some("Work".to_string()), }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect("tag plan");
let p = assert_task_update(&plan, TASK_UUID); assert_eq!(p.get("tg"), Some(&json!([tag2]))); }
#[test] fn edit_checklist_mutations() { let store = build_store(vec![ task(TASK_UUID, "A"), checklist(CHECK_A, TASK_UUID, "Step one", 1), checklist(CHECK_B, TASK_UUID, "Step two", 2), ]);
let new_check = |n: u128| ThingsId::from_u128(n).to_string(); let mut ids = vec![new_check(1), new_check(2)].into_iter(); let mut id_gen = || ids.next().expect("next id"); let args = |remove: &str| EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec!["Step three".to_string(), "Step four".to_string()], remove_checklist: Some(remove.to_string()), rename_checklist: vec![format!("{}:Renamed", &CHECK_A[..6])], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }; // an item cannot be removed and renamed at once let err = build_edit_plan( &args(&format!("{},{}", &CHECK_A[..6], &CHECK_B[..6])), &store, NOW, TODAY, &mut id_gen, ) .expect_err("removed and renamed"); assert!(err.contains("cannot be removed and renamed"), "{err}");
let plan = build_edit_plan(&args(&CHECK_B[..6]), &store, NOW, TODAY, &mut id_gen) .expect("checklist plan");
assert!(matches!( plan.changes.get(CHECK_A).map(|o| o.operation_type), Some(OperationType::Update) )); assert!(matches!( plan.changes.get(CHECK_B).map(|o| o.operation_type), Some(OperationType::Delete) )); assert!(plan.changes.contains_key(&new_check(1))); assert!(plan.changes.contains_key(&new_check(2)));
// two prefixes of one item rename it once at most let twice = EditArgs { add_checklist: Vec::new(), remove_checklist: None, rename_checklist: vec![ format!("{}:First", &CHECK_A[..6]), format!("{}:Second", &CHECK_A[..7]), ], ..args("") }; let err = build_edit_plan(&twice, &store, NOW, TODAY, &mut id_gen).expect_err("renamed twice"); assert!(err.contains("cannot be renamed twice"), "{err}"); }
#[test] fn edit_no_changes_project_and_move_errors() { let store = build_store(vec![task(TASK_UUID, "A")]); let mut id_gen = || "X".to_string(); let err = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("no changes"); assert_eq!(err, "No edit changes requested.");
let store = build_store(vec![task(TASK_UUID, "A"), project(PROJECT_UUID, "Roadmap")]); let err = build_edit_plan( &EditArgs { task_ids: vec![PROJECT_UUID.parse().expect("id")], title: Some("New".to_string()), notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("project edit reject"); assert_eq!(err, "Use 'projects edit' to edit a project.");
let store = build_store(vec![ task(TASK_UUID, "Movable"), task(PROJECT_UUID, "Not a project"), ]); let err = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: Some(PROJECT_UUID.parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("invalid move target kind"); assert_eq!(err, "Container is a to-do: Not a project"); }
#[test] fn edit_move_target_ambiguous() { let ambiguous_project = "ABCD1234efgh5678JKLMno"; let ambiguous_area = "ABCD1234pqrs9123TUVWxy"; let store = build_store(vec![ task(TASK_UUID, "Movable"), project(ambiguous_project, "Project match"), area(ambiguous_area, "Area match"), ]); let mut id_gen = || "X".to_string(); let err = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: None, notes: None, move_target: Some("ABCD1234".parse().expect("id")), tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("ambiguous move target"); assert_eq!( err, "Ambiguous --move target 'ABCD1234' (matches 1 item and 1 area)." ); }
#[test] fn checklist_single_task_constraint_and_empty_title() { let store = build_store(vec![task(TASK_UUID, "A"), task(TASK_UUID2, "B")]); let mut id_gen = || "X".to_string();
let err = build_edit_plan( &EditArgs { task_ids: vec![ TASK_UUID.parse().expect("id"), TASK_UUID2.parse().expect("id"), ], title: None, notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec!["Step".to_string()], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("single to-do constraint"); assert_eq!( err, "--add-checklist/--remove-checklist/--rename-checklist require a single to-do ID." );
let store = build_store(vec![task(TASK_UUID, "A")]); let err = build_edit_plan( &EditArgs { task_ids: vec![TASK_UUID.parse().expect("id")], title: Some(" ".to_string()), notes: None, move_target: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, add_checklist: vec![], remove_checklist: None, rename_checklist: vec![], completed_on: None, created_on: None, when: None, deadline_date: None, clear_deadline: false, reminder: None, clear_reminder: false, repeat: None, times: None, until: None, }, &store, NOW, TODAY, &mut id_gen, ) .expect_err("empty title"); assert_eq!(err, "To-do title cannot be empty."); }
#[test] fn checklist_patch_has_expected_fields() { let patch = ChecklistItemPatch { title: Some("Step".to_string()), status: Some(TaskStatus::Incomplete), stop_date: None, task_ids: Some(vec![ TASK_UUID .parse::<crate::ids::ThingsId>() .expect("test task id should parse as ThingsId"), ]), sort_index: Some(3), creation_date: Some(NOW), modification_date: Some(NOW), }; let props = serde_json::to_value(&patch).expect("patch"); assert_eq!(props.get("tt"), Some(&json!("Step"))); assert_eq!(props.get("ss"), Some(&json!(0))); assert_eq!(props.get("ix"), Some(&json!(3))); }}