Skip to main content

things3_cloud/commands/
projects.rs

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