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 #[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 #[arg(long)]
56 pub detailed: bool,
57}
58
59#[derive(Debug, Args)]
60pub struct ProjectsNewArgs {
61 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 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 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 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}