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