Skip to main content

things3_cloud/commands/
projects.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use anyhow::Result;
4use clap::{Args, Subcommand};
5use iocraft::prelude::*;
6
7use crate::{
8    app::Cli,
9    commands::{Command, TagDeltaArgs, detailed_json_conflict, write_json},
10    common::{
11        DIM,
12        GREEN,
13        ICONS,
14        colored,
15        day_to_timestamp,
16        parse_day,
17        resolve_tag_ids,
18        task6_note,
19    },
20    ids::ThingsId,
21    ui::{
22        render_element_to_string,
23        views::{
24            json::common::build_tasks_json,
25            projects::{ProjectsAreaGroup, ProjectsView},
26        },
27    },
28    wire::{
29        notes::{StructuredTaskNotes, TaskNotes},
30        task::{TaskPatch, TaskProps, TaskStart, TaskStatus, TaskType},
31        wire_object::{EntityType, WireObject},
32    },
33};
34
35#[derive(Debug, Subcommand)]
36pub enum ProjectsSubcommand {
37    #[command(about = "Show all active projects")]
38    List(ProjectsListArgs),
39    #[command(about = "Create a new project")]
40    New(ProjectsNewArgs),
41    #[command(about = "Edit a project title, notes, area, or tags")]
42    Edit(ProjectsEditArgs),
43}
44
45#[derive(Debug, Args)]
46#[command(about = "Show, create, or edit projects")]
47pub struct ProjectsArgs {
48    /// Show notes for each project.
49    #[arg(long, short = 'd')]
50    pub detailed: bool,
51    #[command(subcommand)]
52    pub command: Option<ProjectsSubcommand>,
53}
54
55#[derive(Debug, Default, Args)]
56pub struct ProjectsListArgs {
57    /// Show notes for each task
58    #[arg(long, short = 'd')]
59    pub detailed: bool,
60}
61
62#[derive(Debug, Args)]
63pub struct ProjectsNewArgs {
64    /// Project title
65    pub title: String,
66    #[arg(long, short = 'a', help = "Area UUID/prefix to place the project in")]
67    pub area: Option<String>,
68    #[arg(
69        long,
70        short = 'w',
71        help = "Schedule: anytime (default), someday, today, or YYYY-MM-DD"
72    )]
73    pub when: Option<String>,
74    #[arg(long, short = 'n', default_value = "", help = "Project notes")]
75    pub notes: String,
76    #[arg(
77        long,
78        short = 't',
79        help = "Comma-separated tags (titles or UUID prefixes)"
80    )]
81    pub tags: Option<String>,
82    #[arg(long = "deadline", short = 'd', help = "Deadline date (YYYY-MM-DD)")]
83    pub deadline_date: Option<String>,
84}
85
86#[derive(Debug, Args)]
87pub struct ProjectsEditArgs {
88    /// Project UUID (or unique UUID prefix)
89    pub project_id: String,
90    #[arg(long, short = 't', help = "Replace title")]
91    pub title: Option<String>,
92    #[arg(long = "move", short = 'm', help = "Move to clear or area UUID/prefix")]
93    pub move_target: Option<String>,
94    #[arg(long, short = 'n', help = "Replace notes (use empty string to clear)")]
95    pub notes: Option<String>,
96    #[command(flatten)]
97    pub tag_delta: TagDeltaArgs,
98}
99
100#[derive(Debug, Clone)]
101struct ProjectsEditPlan {
102    project: crate::store::Task,
103    update: TaskPatch,
104    labels: Vec<String>,
105}
106
107fn build_projects_edit_plan(
108    args: &ProjectsEditArgs,
109    store: &crate::store::ThingsStore,
110    now: f64,
111) -> std::result::Result<ProjectsEditPlan, String> {
112    let (project_opt, err, _) = store.resolve_mark_identifier(&args.project_id);
113    let Some(project) = project_opt else {
114        return Err(err);
115    };
116    if !project.is_project() {
117        return Err("The specified ID is not a project.".to_string());
118    }
119
120    let mut update = TaskPatch::default();
121    let mut labels: Vec<String> = Vec::new();
122
123    if let Some(title) = &args.title {
124        let title = title.trim();
125        if title.is_empty() {
126            return Err("Project title cannot be empty.".to_string());
127        }
128        update.title = Some(title.to_string());
129        labels.push("title".to_string());
130    }
131
132    if let Some(notes) = &args.notes {
133        update.notes = Some(if notes.is_empty() {
134            TaskNotes::Structured(StructuredTaskNotes {
135                object_type: Some("tx".to_string()),
136                format_type: 1,
137                ch: Some(0),
138                v: Some(String::new()),
139                ps: Vec::new(),
140                unknown_fields: Default::default(),
141            })
142        } else {
143            task6_note(notes)
144        });
145        labels.push("notes".to_string());
146    }
147
148    if let Some(move_target) = &args.move_target {
149        let move_raw = move_target.trim();
150        let move_l = move_raw.to_lowercase();
151        if move_l == "inbox" {
152            return Err("Projects cannot be moved to Inbox.".to_string());
153        }
154        if move_l == "clear" {
155            update.area_ids = Some(vec![]);
156            labels.push("move=clear".to_string());
157        } else {
158            let (resolved_project, _, _) = store.resolve_mark_identifier(move_raw);
159            let (area, _, _) = store.resolve_area_identifier(move_raw);
160            let project_uuid = resolved_project.as_ref().and_then(|p| {
161                if p.is_project() {
162                    Some(p.uuid.clone())
163                } else {
164                    None
165                }
166            });
167            let area_uuid = area.as_ref().map(|a| a.uuid.clone());
168
169            if project_uuid.is_some() && area_uuid.is_some() {
170                return Err(format!(
171                    "Ambiguous --move target '{}' (matches project and area).",
172                    move_raw
173                ));
174            }
175            if project_uuid.is_some() {
176                return Err("Projects can only be moved to an area or clear.".to_string());
177            }
178            if let Some(area_uuid) = area_uuid {
179                let area_id = area_uuid;
180                update.area_ids = Some(vec![area_id]);
181                labels.push(format!("move={move_raw}"));
182            } else {
183                return Err(format!("Container not found: {move_raw}"));
184            }
185        }
186    }
187
188    let mut current_tags = project.tags.clone();
189    if let Some(add_tags) = &args.tag_delta.add_tags {
190        let (ids, err) = resolve_tag_ids(store, add_tags);
191        if !err.is_empty() {
192            return Err(err);
193        }
194        for id in ids {
195            if !current_tags.iter().any(|t| t == &id) {
196                current_tags.push(id);
197            }
198        }
199        labels.push("add-tags".to_string());
200    }
201    if let Some(remove_tags) = &args.tag_delta.remove_tags {
202        let (ids, err) = resolve_tag_ids(store, remove_tags);
203        if !err.is_empty() {
204            return Err(err);
205        }
206        current_tags.retain(|t| !ids.iter().any(|id| id == t));
207        labels.push("remove-tags".to_string());
208    }
209    if args.tag_delta.add_tags.is_some() || args.tag_delta.remove_tags.is_some() {
210        update.tag_ids = Some(current_tags);
211    }
212
213    if update.is_empty() {
214        return Err("No edit changes requested.".to_string());
215    }
216
217    update.modification_date = Some(now);
218
219    Ok(ProjectsEditPlan {
220        project,
221        update,
222        labels,
223    })
224}
225
226impl Command for ProjectsArgs {
227    fn run_with_ctx(
228        &self,
229        cli: &Cli,
230        out: &mut dyn std::io::Write,
231        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
232    ) -> Result<()> {
233        // Match Python argparse behavior:
234        // - `projects --detailed` (no subcommand) => detailed output
235        // - `projects list --detailed` => detailed output
236        // - `projects --detailed list` => not detailed (subcommand parser default wins)
237        let effective_detailed = match self.command.as_ref() {
238            None => self.detailed,
239            Some(ProjectsSubcommand::List(la)) => la.detailed,
240            _ => false,
241        };
242        let effective_json = match self.command.as_ref() {
243            None | Some(ProjectsSubcommand::List(_)) => cli.json,
244            _ => false,
245        };
246
247        match &self.command {
248            None | Some(ProjectsSubcommand::List(_)) => {
249                let store = Arc::new(cli.load_store()?);
250                let today = ctx.today();
251                let projects = store.projects(Some(TaskStatus::Incomplete));
252
253                if effective_json {
254                    if detailed_json_conflict(effective_json, effective_detailed) {
255                        return Ok(());
256                    }
257                    write_json(out, &build_tasks_json(&projects, &store, &today))?;
258                    return Ok(());
259                }
260
261                let mut by_area: BTreeMap<Option<ThingsId>, Vec<_>> = BTreeMap::new();
262                for p in &projects {
263                    by_area.entry(p.area.clone()).or_default().push(p.clone());
264                }
265
266                let mut id_scope = projects.iter().map(|p| p.uuid.clone()).collect::<Vec<_>>();
267                id_scope.extend(by_area.keys().flatten().cloned());
268                let id_prefix_len = store.unique_prefix_length(&id_scope);
269
270                let no_area = by_area.remove(&None).unwrap_or_default();
271
272                // Sort areas by their index field so output order matches Python
273                let mut area_entries: Vec<(ThingsId, Vec<_>)> = by_area
274                    .into_iter()
275                    .filter_map(|(k, v)| k.map(|uuid| (uuid, v)))
276                    .collect();
277                area_entries.sort_by_key(|(uuid, _)| {
278                    store
279                        .areas_by_uuid
280                        .get(uuid)
281                        .map(|a| a.index)
282                        .unwrap_or(i32::MAX)
283                });
284
285                let area_groups = area_entries
286                    .into_iter()
287                    .map(|(area_uuid, area_projects)| ProjectsAreaGroup {
288                        area_title: store.resolve_area_title(&area_uuid),
289                        area_uuid,
290                        projects: area_projects,
291                    })
292                    .collect::<Vec<_>>();
293
294                let mut ui = element! {
295                        ContextProvider(value: Context::owned(store.clone())) {
296                            ContextProvider(value: Context::owned(today)) {
297                                ProjectsView(
298                                projects_count: projects.len(),
299                                no_area_projects: no_area,
300                                area_groups,
301                                detailed: effective_detailed,
302                                id_prefix_len,
303                            )
304                        }
305                    }
306                };
307                let rendered = render_element_to_string(&mut ui, cli.no_color);
308                writeln!(out, "{}", rendered)?;
309            }
310            Some(ProjectsSubcommand::New(args)) => {
311                let title = args.title.trim();
312                if title.is_empty() {
313                    eprintln!("Project title cannot be empty.");
314                    return Ok(());
315                }
316
317                let store = cli.load_store()?;
318                let now = ctx.now_timestamp();
319                let mut props = TaskProps {
320                    title: title.to_string(),
321                    item_type: TaskType::Project,
322                    status: TaskStatus::Incomplete,
323                    start_location: TaskStart::Anytime,
324                    instance_creation_paused: true,
325                    creation_date: Some(now),
326                    modification_date: Some(now),
327                    ..Default::default()
328                };
329                if !args.notes.is_empty() {
330                    props.notes = Some(task6_note(&args.notes));
331                }
332
333                if let Some(area_id) = &args.area {
334                    let (area_opt, err, _) = store.resolve_area_identifier(area_id);
335                    let Some(area) = area_opt else {
336                        eprintln!("{err}");
337                        return Ok(());
338                    };
339                    props.area_ids = vec![area.uuid.into()];
340                }
341
342                if let Some(when_raw) = &args.when {
343                    let when = when_raw.trim().to_lowercase();
344                    if when == "anytime" {
345                        props.start_location = TaskStart::Anytime;
346                        props.scheduled_date = None;
347                    } else if when == "someday" {
348                        props.start_location = TaskStart::Someday;
349                        props.scheduled_date = None;
350                    } else if when == "today" {
351                        let ts = ctx.today_timestamp();
352                        props.start_location = TaskStart::Anytime;
353                        props.scheduled_date = Some(ts);
354                        props.today_index_reference = Some(ts);
355                    } else {
356                        let day = match parse_day(Some(when_raw), "--when") {
357                            Ok(Some(day)) => day,
358                            Ok(None) => return Ok(()),
359                            Err(e) => {
360                                eprintln!("{e}");
361                                return Ok(());
362                            }
363                        };
364                        let ts = day_to_timestamp(day);
365                        props.start_location = TaskStart::Someday;
366                        props.scheduled_date = Some(ts);
367                        props.today_index_reference = Some(ts);
368                    }
369                }
370
371                if let Some(tags) = &args.tags {
372                    let (tag_ids, err) = resolve_tag_ids(&store, tags);
373                    if !err.is_empty() {
374                        eprintln!("{err}");
375                        return Ok(());
376                    }
377                    props.tag_ids = tag_ids;
378                }
379
380                if let Some(deadline) = &args.deadline_date {
381                    let day = match parse_day(Some(deadline), "--deadline") {
382                        Ok(Some(day)) => day,
383                        Ok(None) => return Ok(()),
384                        Err(e) => {
385                            eprintln!("{e}");
386                            return Ok(());
387                        }
388                    };
389                    props.deadline = Some(day_to_timestamp(day) as i64);
390                }
391
392                let uuid = ctx.next_id();
393
394                let mut changes = BTreeMap::new();
395                changes.insert(uuid.clone(), WireObject::create(EntityType::Task6, props));
396                if let Err(e) = ctx.commit_changes(changes, None) {
397                    eprintln!("Failed to create project: {e}");
398                    return Ok(());
399                }
400
401                writeln!(
402                    out,
403                    "{} {}  {}",
404                    colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
405                    title,
406                    colored(&uuid, &[DIM], cli.no_color)
407                )?;
408            }
409            Some(ProjectsSubcommand::Edit(args)) => {
410                let store = cli.load_store()?;
411                let plan = match build_projects_edit_plan(args, &store, ctx.now_timestamp()) {
412                    Ok(plan) => plan,
413                    Err(err) => {
414                        eprintln!("{err}");
415                        return Ok(());
416                    }
417                };
418
419                let mut changes = BTreeMap::new();
420                changes.insert(
421                    plan.project.uuid.to_string(),
422                    WireObject::update(
423                        EntityType::from(plan.project.entity.clone()),
424                        plan.update.clone(),
425                    ),
426                );
427                if let Err(e) = ctx.commit_changes(changes, None) {
428                    eprintln!("Failed to edit project: {e}");
429                    return Ok(());
430                }
431
432                let title = plan.update.title.as_deref().unwrap_or(&plan.project.title);
433                writeln!(
434                    out,
435                    "{} {}  {} {}",
436                    colored(&format!("{} Edited", ICONS.done), &[GREEN], cli.no_color),
437                    title,
438                    colored(&plan.project.uuid, &[DIM], cli.no_color),
439                    colored(
440                        &format!("({})", plan.labels.join(", ")),
441                        &[DIM],
442                        cli.no_color
443                    )
444                )?;
445            }
446        }
447        Ok(())
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use serde_json::json;
454
455    use super::*;
456    use crate::{
457        ids::ThingsId,
458        store::{ThingsStore, fold_items},
459        wire::{
460            area::AreaProps,
461            tags::TagProps,
462            task::{TaskProps, TaskStart, TaskStatus, TaskType},
463            wire_object::{EntityType, WireItem, WireObject},
464        },
465    };
466
467    const NOW: f64 = 1_700_000_222.0;
468    const PROJECT_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
469
470    fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
471        let mut item: WireItem = BTreeMap::new();
472        for (uuid, obj) in entries {
473            item.insert(uuid, obj);
474        }
475        ThingsStore::from_raw_state(&fold_items([item]))
476    }
477
478    fn project(uuid: &str, title: &str, tags: Vec<&str>) -> (String, WireObject) {
479        (
480            uuid.to_string(),
481            WireObject::create(
482                EntityType::Task6,
483                TaskProps {
484                    title: title.to_string(),
485                    item_type: TaskType::Project,
486                    status: TaskStatus::Incomplete,
487                    start_location: TaskStart::Anytime,
488                    sort_index: 0,
489                    tag_ids: tags
490                        .iter()
491                        .map(|t| {
492                            t.parse::<ThingsId>()
493                                .expect("test tag id should parse as ThingsId")
494                        })
495                        .collect(),
496                    creation_date: Some(1.0),
497                    modification_date: Some(1.0),
498                    ..Default::default()
499                },
500            ),
501        )
502    }
503
504    fn area(uuid: &str, title: &str) -> (String, WireObject) {
505        (
506            uuid.to_string(),
507            WireObject::create(
508                EntityType::Area3,
509                AreaProps {
510                    title: title.to_string(),
511                    sort_index: 0,
512                    ..Default::default()
513                },
514            ),
515        )
516    }
517
518    fn tag(uuid: &str, title: &str) -> (String, WireObject) {
519        (
520            uuid.to_string(),
521            WireObject::create(
522                EntityType::Tag4,
523                TagProps {
524                    title: title.to_string(),
525                    sort_index: 0,
526                    ..Default::default()
527                },
528            ),
529        )
530    }
531
532    #[test]
533    fn projects_edit_payload_variants() {
534        let target_area_uuid = "JFdhhhp37fpryAKu8UXwzK";
535        let store = build_store(vec![
536            project(PROJECT_UUID, "Roadmap", vec![]),
537            area(target_area_uuid, "Personal"),
538        ]);
539
540        let title_plan = build_projects_edit_plan(
541            &ProjectsEditArgs {
542                project_id: PROJECT_UUID.to_string(),
543                title: Some("Roadmap v2".to_string()),
544                move_target: None,
545                notes: None,
546                tag_delta: TagDeltaArgs {
547                    add_tags: None,
548                    remove_tags: None,
549                },
550            },
551            &store,
552            NOW,
553        )
554        .expect("title plan");
555        let p = title_plan.update.into_properties();
556        assert_eq!(p.get("tt"), Some(&json!("Roadmap v2")));
557        assert_eq!(p.get("md"), Some(&json!(NOW)));
558
559        let clear_plan = build_projects_edit_plan(
560            &ProjectsEditArgs {
561                project_id: PROJECT_UUID.to_string(),
562                title: None,
563                move_target: Some("clear".to_string()),
564                notes: None,
565                tag_delta: TagDeltaArgs {
566                    add_tags: None,
567                    remove_tags: None,
568                },
569            },
570            &store,
571            NOW,
572        )
573        .expect("clear plan");
574        assert_eq!(
575            clear_plan.update.into_properties().get("ar"),
576            Some(&json!([]))
577        );
578
579        let move_plan = build_projects_edit_plan(
580            &ProjectsEditArgs {
581                project_id: PROJECT_UUID.to_string(),
582                title: None,
583                move_target: Some(target_area_uuid.to_string()),
584                notes: None,
585                tag_delta: TagDeltaArgs {
586                    add_tags: None,
587                    remove_tags: None,
588                },
589            },
590            &store,
591            NOW,
592        )
593        .expect("move area plan");
594        assert_eq!(
595            move_plan.update.into_properties().get("ar"),
596            Some(&json!([target_area_uuid]))
597        );
598    }
599
600    #[test]
601    fn projects_edit_tags_and_errors() {
602        let tag1 = "WukwpDdL5Z88nX3okGMKTC";
603        let tag2 = "JiqwiDaS3CAyjCmHihBDnB";
604        let store = build_store(vec![
605            project(PROJECT_UUID, "Roadmap", vec![tag1, tag2]),
606            tag(tag1, "Work"),
607            tag(tag2, "Focus"),
608        ]);
609
610        let remove_plan = build_projects_edit_plan(
611            &ProjectsEditArgs {
612                project_id: PROJECT_UUID.to_string(),
613                title: None,
614                move_target: None,
615                notes: None,
616                tag_delta: TagDeltaArgs {
617                    add_tags: None,
618                    remove_tags: Some("Work".to_string()),
619                },
620            },
621            &store,
622            NOW,
623        )
624        .expect("remove tags");
625        assert_eq!(
626            remove_plan.update.into_properties().get("tg"),
627            Some(&json!([tag2]))
628        );
629
630        let no_change = build_projects_edit_plan(
631            &ProjectsEditArgs {
632                project_id: PROJECT_UUID.to_string(),
633                title: None,
634                move_target: None,
635                notes: None,
636                tag_delta: TagDeltaArgs {
637                    add_tags: None,
638                    remove_tags: None,
639                },
640            },
641            &store,
642            NOW,
643        )
644        .expect_err("no changes");
645        assert_eq!(no_change, "No edit changes requested.");
646
647        let inbox = build_projects_edit_plan(
648            &ProjectsEditArgs {
649                project_id: PROJECT_UUID.to_string(),
650                title: None,
651                move_target: Some("inbox".to_string()),
652                notes: None,
653                tag_delta: TagDeltaArgs {
654                    add_tags: None,
655                    remove_tags: None,
656                },
657            },
658            &store,
659            NOW,
660        )
661        .expect_err("cannot move inbox");
662        assert_eq!(inbox, "Projects cannot be moved to Inbox.");
663    }
664}