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 #[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 #[arg(long)]
42 pub detailed: bool,
43}
44
45#[derive(Debug, Args)]
46pub struct ProjectsNewArgs {
47 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 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 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 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}