Skip to main content

things3_cloud/commands/
new.rs

1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{
4    DIM, GREEN, ICONS, colored, day_to_timestamp, parse_day, resolve_tag_ids, task6_note,
5};
6use crate::store::Task;
7use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
8use crate::wire::wire_object::{EntityType, WireObject};
9use anyhow::Result;
10use chrono::{TimeZone, Utc};
11use clap::Args;
12use serde_json::json;
13use std::cmp::Reverse;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Args)]
17#[command(about = "Create a new task")]
18pub struct NewArgs {
19    /// Task title
20    pub title: String,
21    #[arg(long = "in", default_value = "inbox", help = "Container: inbox, clear, project UUID/prefix, or area UUID/prefix")]
22    pub in_target: String,
23    #[arg(long, help = "Schedule: anytime, someday, today, evening, or YYYY-MM-DD")]
24    pub when: Option<String>,
25    #[arg(long = "before", help = "Insert before this sibling task UUID/prefix")]
26    pub before_id: Option<String>,
27    #[arg(long = "after", help = "Insert after this sibling task UUID/prefix")]
28    pub after_id: Option<String>,
29    #[arg(long, default_value = "", help = "Task notes")]
30    pub notes: String,
31    #[arg(long, help = "Comma-separated tags (titles or UUID prefixes)")]
32    pub tags: Option<String>,
33    #[arg(long = "deadline", help = "Deadline date (YYYY-MM-DD)")]
34    pub deadline_date: Option<String>,
35}
36
37fn base_new_props(title: &str, now: f64) -> TaskProps {
38    TaskProps {
39        title: title.to_string(),
40        item_type: TaskType::Todo,
41        status: TaskStatus::Incomplete,
42        start_location: TaskStart::Inbox,
43        creation_date: Some(now),
44        modification_date: Some(now),
45        conflict_overrides: Some(json!({"_t": "oo", "sn": {}})),
46        ..Default::default()
47    }
48}
49
50fn task_bucket(task: &Task, store: &crate::store::ThingsStore) -> Vec<String> {
51    if task.is_heading() {
52        return vec![
53            "heading".to_string(),
54            task.project.clone().map(|v| v.to_string()).unwrap_or_default(),
55        ];
56    }
57    if task.is_project() {
58        return vec![
59            "project".to_string(),
60            task.area.clone().map(|v| v.to_string()).unwrap_or_default(),
61        ];
62    }
63    if let Some(project_uuid) = store.effective_project_uuid(task) {
64        return vec![
65            "task-project".to_string(),
66            project_uuid.to_string(),
67            task.action_group
68                .clone()
69                .map(|v| v.to_string())
70                .unwrap_or_default(),
71        ];
72    }
73    if let Some(area_uuid) = store.effective_area_uuid(task) {
74        return vec![
75            "task-area".to_string(),
76            area_uuid.to_string(),
77            i32::from(task.start).to_string(),
78        ];
79    }
80    vec!["task-root".to_string(), i32::from(task.start).to_string()]
81}
82
83fn props_bucket(props: &TaskProps) -> Vec<String> {
84    if let Some(project_uuid) = props.parent_project_ids.first() {
85        return vec![
86            "task-project".to_string(),
87            project_uuid.to_string(),
88            String::new(),
89        ];
90    }
91    if let Some(area_uuid) = props.area_ids.first() {
92        let st = i32::from(props.start_location);
93        return vec![
94            "task-area".to_string(),
95            area_uuid.to_string(),
96            st.to_string(),
97        ];
98    }
99    let st = i32::from(props.start_location);
100    vec!["task-root".to_string(), st.to_string()]
101}
102
103fn plan_ix_insert(ordered: &[Task], insert_at: usize) -> (i32, Vec<(String, i32, String)>) {
104    let prev_ix = if insert_at > 0 {
105        Some(ordered[insert_at - 1].index)
106    } else {
107        None
108    };
109    let next_ix = if insert_at < ordered.len() {
110        Some(ordered[insert_at].index)
111    } else {
112        None
113    };
114    let mut updates = Vec::new();
115
116    if prev_ix.is_none() && next_ix.is_none() {
117        return (0, updates);
118    }
119    if prev_ix.is_none() {
120        return (next_ix.unwrap_or(0) - 1, updates);
121    }
122    if next_ix.is_none() {
123        return (prev_ix.unwrap_or(0) + 1, updates);
124    }
125    if prev_ix.unwrap_or(0) + 1 < next_ix.unwrap_or(0) {
126        return ((prev_ix.unwrap_or(0) + next_ix.unwrap_or(0)) / 2, updates);
127    }
128
129    let stride = 1024;
130    let mut new_index = stride;
131    let mut idx = 1;
132    for i in 0..=ordered.len() {
133        let target_ix = idx * stride;
134        if i == insert_at {
135            new_index = target_ix;
136            idx += 1;
137            continue;
138        }
139        let source_idx = if i < insert_at { i } else { i - 1 };
140        if source_idx < ordered.len() {
141            let entry = &ordered[source_idx];
142            if entry.index != target_ix {
143                updates.push((entry.uuid.to_string(), target_ix, entry.entity.clone()));
144            }
145            idx += 1;
146        }
147    }
148    (new_index, updates)
149}
150
151#[derive(Debug, Clone)]
152struct NewPlan {
153    new_uuid: String,
154    changes: BTreeMap<String, WireObject>,
155    title: String,
156}
157
158fn build_new_plan(
159    args: &NewArgs,
160    store: &crate::store::ThingsStore,
161    now: f64,
162    today_ts: i64,
163    next_id: &mut dyn FnMut() -> String,
164) -> std::result::Result<NewPlan, String> {
165    let today = Utc
166        .timestamp_opt(today_ts, 0)
167        .single()
168        .unwrap_or_else(Utc::now)
169        .date_naive()
170        .and_hms_opt(0, 0, 0)
171        .map(|d| Utc.from_utc_datetime(&d))
172        .unwrap_or_else(Utc::now);
173    let title = args.title.trim();
174    if title.is_empty() {
175        return Err("Task title cannot be empty.".to_string());
176    }
177
178    let mut props = base_new_props(title, now);
179    if !args.notes.is_empty() {
180        props.notes = Some(task6_note(&args.notes));
181    }
182
183    let anchor_id = args.before_id.as_ref().or(args.after_id.as_ref());
184    let mut anchor: Option<Task> = None;
185    if let Some(anchor_id) = anchor_id {
186        let (task, err, _ambiguous) = store.resolve_task_identifier(anchor_id);
187        if task.is_none() {
188            return Err(err);
189        }
190        anchor = task;
191    }
192
193    let in_target = args.in_target.trim();
194    if !in_target.eq_ignore_ascii_case("inbox") {
195        let (project, _, _) = store.resolve_mark_identifier(in_target);
196        let (area, _, _) = store.resolve_area_identifier(in_target);
197        let project_uuid = project.as_ref().and_then(|p| {
198            if p.is_project() {
199                Some(p.uuid.clone())
200            } else {
201                None
202            }
203        });
204        let area_uuid = area.map(|a| a.uuid);
205
206        if project_uuid.is_some() && area_uuid.is_some() {
207            return Err(format!(
208                "Ambiguous --in target '{}' (matches project and area).",
209                in_target
210            ));
211        }
212
213        if project.is_some() && project_uuid.is_none() {
214            return Err("--in target must be inbox, a project ID, or an area ID.".to_string());
215        }
216
217        if let Some(project_uuid) = project_uuid {
218            props.parent_project_ids = vec![project_uuid.into()];
219            props.start_location = TaskStart::Anytime;
220        } else if let Some(area_uuid) = area_uuid {
221            props.area_ids = vec![area_uuid.into()];
222            props.start_location = TaskStart::Anytime;
223        } else {
224            return Err(format!("Container not found: {}", in_target));
225        }
226    }
227
228    if let Some(when_raw) = &args.when {
229        let when = when_raw.trim();
230        if when.eq_ignore_ascii_case("anytime") {
231            props.start_location = TaskStart::Anytime;
232            props.scheduled_date = None;
233        } else if when.eq_ignore_ascii_case("someday") {
234            props.start_location = TaskStart::Someday;
235            props.scheduled_date = None;
236        } else if when.eq_ignore_ascii_case("today") {
237            props.start_location = TaskStart::Anytime;
238            props.scheduled_date = Some(today_ts);
239            props.today_index_reference = Some(today_ts);
240        } else {
241            let parsed = match parse_day(Some(when), "--when") {
242                Ok(Some(day)) => day,
243                Ok(None) => {
244                    return Err("--when requires anytime, someday, today, or YYYY-MM-DD".to_string());
245                }
246                Err(err) => return Err(err),
247            };
248            let day_ts = day_to_timestamp(parsed);
249            props.start_location = TaskStart::Someday;
250            props.scheduled_date = Some(day_ts);
251            props.today_index_reference = Some(day_ts);
252        }
253    }
254
255    if let Some(tags) = &args.tags {
256        let (tag_ids, tag_err) = resolve_tag_ids(store, tags);
257        if !tag_err.is_empty() {
258            return Err(tag_err);
259        }
260        props.tag_ids = tag_ids;
261    }
262
263    if let Some(deadline_date) = &args.deadline_date {
264        let parsed = match parse_day(Some(deadline_date), "--deadline") {
265            Ok(Some(day)) => day,
266            Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()),
267            Err(err) => return Err(err),
268        };
269        props.deadline = Some(day_to_timestamp(parsed) as i64);
270    }
271
272    let anchor_is_today = anchor
273        .as_ref()
274        .map(|a| a.start == TaskStart::Anytime && (a.is_today(&today) || a.evening))
275        .unwrap_or(false);
276    let target_bucket = props_bucket(&props);
277
278    if let Some(anchor) = &anchor
279        && !anchor_is_today && task_bucket(anchor, store) != target_bucket
280    {
281        return Err("Cannot place new task relative to an item in a different container/list.".to_string());
282    }
283
284    let mut index_updates: Vec<(String, i32, String)> = Vec::new();
285    let mut siblings = store
286        .tasks_by_uuid
287        .values()
288        .filter(|t| !t.trashed && t.status == TaskStatus::Incomplete && task_bucket(t, store) == target_bucket)
289        .cloned()
290        .collect::<Vec<_>>();
291    siblings.sort_by_key(|t| (t.index, t.uuid.clone()));
292
293    let mut structural_insert_at = 0usize;
294    if let Some(anchor) = &anchor
295        && task_bucket(anchor, store) == target_bucket
296    {
297        let anchor_pos = siblings.iter().position(|t| t.uuid == anchor.uuid);
298        let Some(anchor_pos) = anchor_pos else {
299            return Err("Anchor not found in target list.".to_string());
300        };
301        structural_insert_at = if args.before_id.is_some() { anchor_pos } else { anchor_pos + 1 };
302    }
303
304    let (structural_ix, structural_updates) = plan_ix_insert(&siblings, structural_insert_at);
305    props.sort_index = structural_ix;
306    index_updates.extend(structural_updates);
307
308    let new_is_today = props.start_location == TaskStart::Anytime
309        && props.scheduled_date.map_or(false, |sr| sr <= today_ts);
310    if new_is_today && anchor_is_today {
311        let mut section_evening = if props.evening_bit != 0 {
312            1
313        } else {
314            0
315        };
316
317        if anchor_is_today
318            && let Some(anchor) = &anchor
319        {
320            section_evening = if anchor.evening { 1 } else { 0 };
321            props.evening_bit = section_evening;
322        }
323
324        let mut today_siblings = store
325            .tasks_by_uuid
326            .values()
327            .filter(|t| {
328                !t.trashed
329                    && t.status == TaskStatus::Incomplete
330                    && t.start == TaskStart::Anytime
331                    && (t.is_today(&today) || t.evening)
332                    && (if t.evening { 1 } else { 0 }) == section_evening
333            })
334            .cloned()
335            .collect::<Vec<_>>();
336        today_siblings.sort_by_key(|task| {
337            let tir = task.today_index_reference.unwrap_or(0);
338            (Reverse(tir), task.today_index, Reverse(task.index))
339        });
340
341        let mut today_insert_at = 0usize;
342        if anchor_is_today
343            && let Some(anchor) = &anchor
344            && (if anchor.evening { 1 } else { 0 }) == section_evening
345            && let Some(anchor_pos) = today_siblings.iter().position(|t| t.uuid == anchor.uuid)
346        {
347            today_insert_at = if args.before_id.is_some() { anchor_pos } else { anchor_pos + 1 };
348        }
349
350        let prev_today = if today_insert_at > 0 {
351            today_siblings.get(today_insert_at - 1)
352        } else {
353            None
354        };
355        let next_today = today_siblings.get(today_insert_at);
356
357        if let Some(next_today) = next_today {
358            let next_tir = next_today.today_index_reference.unwrap_or(today_ts);
359            props.today_index_reference = Some(next_tir);
360            props.today_sort_index = next_today.today_index - 1;
361        } else if let Some(prev_today) = prev_today {
362            let prev_tir = prev_today.today_index_reference.unwrap_or(today_ts);
363            props.today_index_reference = Some(prev_tir);
364            props.today_sort_index = prev_today.today_index + 1;
365        } else {
366            props.today_index_reference = Some(today_ts);
367            props.today_sort_index = 0;
368        }
369    }
370
371    let new_uuid = next_id();
372
373    let mut changes = BTreeMap::new();
374    changes.insert(
375        new_uuid.clone(),
376        WireObject::create(EntityType::Task6, props.clone()),
377    );
378
379    for (task_uuid, task_index, task_entity) in index_updates {
380        use crate::wire::task::TaskPatch;
381        changes.insert(
382            task_uuid,
383            WireObject::update(EntityType::from(task_entity), TaskPatch {
384                sort_index: Some(task_index),
385                modification_date: Some(now),
386                ..Default::default()
387            }),
388        );
389    }
390
391    Ok(NewPlan {
392        new_uuid,
393        changes,
394        title: title.to_string(),
395    })
396}
397
398impl Command for NewArgs {
399    fn run_with_ctx(
400        &self,
401        cli: &Cli,
402        out: &mut dyn std::io::Write,
403        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
404    ) -> Result<()> {
405        let store = cli.load_store()?;
406        let now = ctx.now_timestamp();
407        let today = ctx.today_timestamp();
408        let mut id_gen = || ctx.next_id();
409        let plan = match build_new_plan(self, &store, now, today, &mut id_gen) {
410            Ok(plan) => plan,
411            Err(err) => {
412                eprintln!("{err}");
413                return Ok(());
414            }
415        };
416
417        if let Err(e) = ctx.commit_changes(plan.changes, None) {
418            eprintln!("Failed to create task: {e}");
419            return Ok(());
420        }
421
422        writeln!(
423            out,
424            "{} {}  {}",
425            colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
426            plan.title,
427            colored(&plan.new_uuid, &[DIM], cli.no_color)
428        )?;
429        Ok(())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::store::{ThingsStore, fold_items};
437    use crate::wire::area::AreaProps;
438    use crate::wire::tags::TagProps;
439    use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
440    use serde_json::json;
441
442    const NOW: f64 = 1_700_000_000.0;
443    const NEW_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf";
444    const INBOX_ANCHOR_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf";
445    const INBOX_OTHER_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
446    const PROJECT_UUID: &str = "JFdhhhp37fpryAKu8UXwzK";
447    const AREA_UUID: &str = "74rgJf6Qh9wYp2TcVk8mNB";
448    const TAG_A_UUID: &str = "By8mN2qRk5Wv7Xc9Dt3HpL";
449    const TAG_B_UUID: &str = "Cv9nP3sTk6Xw8Yd4Eu5JqM";
450    const TODAY: i64 = 1_700_000_000;
451
452    fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
453        let mut item = BTreeMap::new();
454        for (uuid, obj) in entries {
455            item.insert(uuid, obj);
456        }
457        ThingsStore::from_raw_state(&fold_items([item]))
458    }
459
460    fn task(uuid: &str, title: &str, st: i32, ix: i32, sr: Option<i64>, tir: Option<i64>, ti: i32) -> (String, WireObject) {
461        (
462            uuid.to_string(),
463            WireObject::create(
464                EntityType::Task6,
465                TaskProps {
466                    title: title.to_string(),
467                    item_type: TaskType::Todo,
468                    status: TaskStatus::Incomplete,
469                    start_location: TaskStart::from(st),
470                    sort_index: ix,
471                    scheduled_date: sr,
472                    today_index_reference: tir,
473                    today_sort_index: ti,
474                    creation_date: Some(1.0),
475                    modification_date: Some(1.0),
476                    ..Default::default()
477                },
478            ),
479        )
480    }
481
482    fn project(uuid: &str, title: &str) -> (String, WireObject) {
483        (
484            uuid.to_string(),
485            WireObject::create(
486                EntityType::Task6,
487                TaskProps {
488                    title: title.to_string(),
489                    item_type: TaskType::Project,
490                    status: TaskStatus::Incomplete,
491                    start_location: TaskStart::Anytime,
492                    sort_index: 0,
493                    creation_date: Some(1.0),
494                    modification_date: Some(1.0),
495                    ..Default::default()
496                },
497            ),
498        )
499    }
500
501    fn area(uuid: &str, title: &str) -> (String, WireObject) {
502        (
503            uuid.to_string(),
504            WireObject::create(
505                EntityType::Area3,
506                AreaProps {
507                    title: title.to_string(),
508                    sort_index: 0,
509                    ..Default::default()
510                },
511            ),
512        )
513    }
514
515    fn tag(uuid: &str, title: &str) -> (String, WireObject) {
516        (
517            uuid.to_string(),
518            WireObject::create(
519                EntityType::Tag4,
520                TagProps {
521                    title: title.to_string(),
522                    sort_index: 0,
523                    ..Default::default()
524                },
525            ),
526        )
527    }
528
529    #[test]
530    fn new_payload_parity_cases() {
531        let mut id_gen = || NEW_UUID.to_string();
532
533        let bare = build_new_plan(
534            &NewArgs {
535                title: "Ship release".to_string(),
536                in_target: "inbox".to_string(),
537                when: None,
538                before_id: None,
539                after_id: None,
540                notes: String::new(),
541                tags: None,
542                deadline_date: None,
543            },
544            &build_store(vec![]),
545            NOW,
546            TODAY,
547            &mut id_gen,
548        )
549        .expect("bare");
550        let bare_json = serde_json::to_value(bare.changes).expect("to value");
551        assert_eq!(bare_json[NEW_UUID]["t"], json!(0));
552        assert_eq!(bare_json[NEW_UUID]["e"], json!("Task6"));
553        assert_eq!(bare_json[NEW_UUID]["p"]["tt"], json!("Ship release"));
554        assert_eq!(bare_json[NEW_UUID]["p"]["st"], json!(0));
555        assert_eq!(bare_json[NEW_UUID]["p"]["cd"], json!(NOW));
556        assert_eq!(bare_json[NEW_UUID]["p"]["md"], json!(NOW));
557
558        let when_today = build_new_plan(
559            &NewArgs {
560                title: "Task today".to_string(),
561                in_target: "inbox".to_string(),
562                when: Some("today".to_string()),
563                before_id: None,
564                after_id: None,
565                notes: String::new(),
566                tags: None,
567                deadline_date: None,
568            },
569            &build_store(vec![]),
570            NOW,
571            TODAY,
572            &mut id_gen,
573        )
574        .expect("today");
575        let p = &serde_json::to_value(when_today.changes).expect("to value")[NEW_UUID]["p"];
576        assert_eq!(p["st"], json!(1));
577        assert_eq!(p["sr"], json!(TODAY));
578        assert_eq!(p["tir"], json!(TODAY));
579
580        let full_store = build_store(vec![
581            project(PROJECT_UUID, "Roadmap"),
582            area(AREA_UUID, "Work"),
583            tag(TAG_A_UUID, "urgent"),
584            tag(TAG_B_UUID, "backend"),
585        ]);
586        let in_project = build_new_plan(
587            &NewArgs {
588                title: "Project task".to_string(),
589                in_target: PROJECT_UUID.to_string(),
590                when: None,
591                before_id: None,
592                after_id: None,
593                notes: "line one".to_string(),
594                tags: Some("urgent,backend".to_string()),
595                deadline_date: Some("2032-05-06".to_string()),
596            },
597            &full_store,
598            NOW,
599            TODAY,
600            &mut id_gen,
601        )
602        .expect("in project");
603        let p = &serde_json::to_value(in_project.changes).expect("to value")[NEW_UUID]["p"];
604        let deadline_ts = day_to_timestamp(
605            parse_day(Some("2032-05-06"), "--deadline")
606                .expect("parse")
607                .expect("day"),
608        );
609        assert_eq!(p["pr"], json!([PROJECT_UUID]));
610        assert_eq!(p["st"], json!(1));
611        assert_eq!(p["tg"], json!([TAG_A_UUID, TAG_B_UUID]));
612        assert_eq!(p["dd"], json!(deadline_ts));
613    }
614
615    #[test]
616    fn new_after_gap_and_rebalance() {
617        let mut id_gen = || NEW_UUID.to_string();
618        let gap_store = build_store(vec![
619            task(INBOX_ANCHOR_UUID, "Anchor", 0, 1024, None, None, 0),
620            task(INBOX_OTHER_UUID, "Other", 0, 2048, None, None, 0),
621        ]);
622        let gap = build_new_plan(
623            &NewArgs {
624                title: "Inserted".to_string(),
625                in_target: "inbox".to_string(),
626                when: None,
627                before_id: None,
628                after_id: Some(INBOX_ANCHOR_UUID.to_string()),
629                notes: String::new(),
630                tags: None,
631                deadline_date: None,
632            },
633            &gap_store,
634            NOW,
635            TODAY,
636            &mut id_gen,
637        )
638        .expect("gap");
639        assert_eq!(
640            serde_json::to_value(gap.changes).expect("to value")[NEW_UUID]["p"]["ix"],
641            json!(1536)
642        );
643
644        let rebalance_store = build_store(vec![
645            task(INBOX_ANCHOR_UUID, "Anchor", 0, 1024, None, None, 0),
646            task(INBOX_OTHER_UUID, "Other", 0, 1025, None, None, 0),
647        ]);
648        let rebalance = build_new_plan(
649            &NewArgs {
650                title: "Inserted".to_string(),
651                in_target: "inbox".to_string(),
652                when: None,
653                before_id: None,
654                after_id: Some(INBOX_ANCHOR_UUID.to_string()),
655                notes: String::new(),
656                tags: None,
657                deadline_date: None,
658            },
659            &rebalance_store,
660            NOW,
661            TODAY,
662            &mut id_gen,
663        )
664        .expect("rebalance");
665        let rb = serde_json::to_value(rebalance.changes).expect("to value");
666        assert_eq!(rb[NEW_UUID]["p"]["ix"], json!(2048));
667        assert_eq!(rb[INBOX_OTHER_UUID]["p"], json!({"ix":3072,"md":NOW}));
668    }
669
670    #[test]
671    fn new_rejections() {
672        let mut id_gen = || NEW_UUID.to_string();
673        let empty_title = build_new_plan(
674            &NewArgs {
675                title: "   ".to_string(),
676                in_target: "inbox".to_string(),
677                when: None,
678                before_id: None,
679                after_id: None,
680                notes: String::new(),
681                tags: None,
682                deadline_date: None,
683            },
684            &build_store(vec![]),
685            NOW,
686            TODAY,
687            &mut id_gen,
688        )
689        .expect_err("empty title");
690        assert_eq!(empty_title, "Task title cannot be empty.");
691
692        let unknown_container = build_new_plan(
693            &NewArgs {
694                title: "Ship".to_string(),
695                in_target: "nope".to_string(),
696                when: None,
697                before_id: None,
698                after_id: None,
699                notes: String::new(),
700                tags: None,
701                deadline_date: None,
702            },
703            &build_store(vec![]),
704            NOW,
705            TODAY,
706            &mut id_gen,
707        )
708        .expect_err("unknown container");
709        assert_eq!(unknown_container, "Container not found: nope");
710    }
711}