Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666use std::{collections::BTreeMap, sync::Arc};
use anyhow::{Context as _, Result, bail};use clap::{Args, Subcommand};use iocraft::prelude::*;
use crate::{ app::Cli, arg_types::IdentifierToken, commands::{Command, TagDeltaArgs, detailed_json_conflict, write_json}, common::{ DIM, GREEN, ICONS, ambiguous_target, colored, day_timestamp, kind_with_article, one_line, parse_day, resolve_removable_tag_ids, resolve_tag_ids, shown_title, task6_note, }, ids::ThingsId, ui::{ render_element_to_string, views::{ json::common::build_tasks_json, projects::{ProjectsAreaGroup, ProjectsView}, }, }, wire::{ notes::{StructuredTaskNotes, TaskNotes}, task::{TaskPatch, TaskProps, TaskStart, TaskStatus, TaskType}, wire_object::{EntityType, WireObject}, },};
#[derive(Subcommand)]pub enum ProjectsSubcommand { #[command(about = "Show all incomplete projects")] List(ProjectsListArgs), #[command(about = "Create a new project")] New(ProjectsNewArgs), #[command(about = "Edit a project title, notes, area, or tags")] Edit(ProjectsEditArgs),}
#[derive(Args)]#[command(about = "Show, create, or edit projects")]pub struct ProjectsArgs { /// Show notes for each project #[arg(long, short = 'd')] pub detailed: bool, #[command(subcommand)] pub command: Option<ProjectsSubcommand>,}
#[derive(Args)]pub struct ProjectsListArgs { /// Show notes for each project #[arg(long, short = 'd')] pub detailed: bool,}
#[derive(Args)]pub struct ProjectsNewArgs { /// Project title pub title: String, #[arg(long, short = 'a', help = "Area ID or prefix to place the project in")] pub area: Option<IdentifierToken>, #[arg( long, short = 'w', help = "When: anytime (default), someday, today, or YYYY-MM-DD" )] pub when: Option<String>, #[arg(long, short = 'n', default_value = "", help = "Project notes")] pub notes: String, #[arg( long, short = 't', help = "Comma-separated tags (titles or ID prefixes)" )] pub tags: Option<String>, #[arg(long = "deadline", short = 'd', help = "Deadline date (YYYY-MM-DD)")] pub deadline_date: Option<String>,}
#[derive(Args)]pub struct ProjectsEditArgs { /// Project ID (or unique ID prefix) pub project_id: IdentifierToken, #[arg(long, short = 't', help = "Replace title")] pub title: Option<String>, #[arg( long = "move", short = 'm', help = "Move to clear or area ID or prefix" )] pub move_target: Option<IdentifierToken>, #[arg(long, short = 'n', help = "Replace notes (use empty string to clear)")] pub notes: Option<String>, #[command(flatten)] pub tag_delta: TagDeltaArgs,}
#[derive(Debug)]struct ProjectsEditPlan { project: crate::store::Task, update: TaskPatch, labels: Vec<String>,}
/// the area `target` names for a project, or why a project cannot go there////// `flag` names the option in messages/// `places` says where a project can gofn resolve_project_area( store: &crate::store::ThingsStore, target: &str, flag: &str, places: &str,) -> std::result::Result<ThingsId, String> { let (item, _, item_candidates) = store.resolve_task_identifier(target); let (area, _, area_candidates) = store.resolve_area_identifier(target); let items = if item.is_some() { 1 } else { item_candidates.len() }; let areas = if area.is_some() { 1 } else { area_candidates.len() }; if let Some(message) = ambiguous_target(flag, target, items, areas) { return Err(message); } match (item, area) { // a project, heading or to-do is no place for a project, wherever it is (Some(item), _) => Err(format!( "Container is {}, and projects go into {places}: {}", kind_with_article(&item), shown_title(&item.title) )), // an area that did not replay completely may not be what it shows (None, Some(area)) if area.degraded => Err(format!( "Container did not replay completely: {}", shown_title(&area.title) )), (None, Some(area)) => Ok(area.uuid), (None, None) => Err(format!("Container not found: {target}")), }}
fn build_projects_edit_plan( args: &ProjectsEditArgs, store: &crate::store::ThingsStore, now: f64,) -> std::result::Result<ProjectsEditPlan, String> { let (project_opt, err, _) = store.resolve_mark_identifier(&args.project_id); let Some(project) = project_opt else { return Err(err); }; if !project.is_project() { return Err(format!( "Item is {}: {}", kind_with_article(&project), shown_title(&project.title) )); }
let mut update = TaskPatch::default(); let mut labels: Vec<String> = Vec::new();
if let Some(title) = &args.title { let title = title.trim(); if title.is_empty() { return Err("Project title cannot be empty.".to_string()); } update.title = Some(title.to_string()); labels.push("title".to_string()); }
if let Some(notes) = &args.notes { update.notes = Some(if notes.is_empty() { TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 1, ch: Some(0), v: Some(String::new()), ps: Vec::new(), unknown_fields: Default::default(), }) } else { task6_note(notes) }); labels.push("notes".to_string()); }
if let Some(move_target) = &args.move_target { let move_raw = &**move_target; let move_l = move_raw.to_lowercase(); if move_l == "inbox" { return Err("Projects cannot be moved to Inbox.".to_string()); } if move_l == "clear" { update.area_ids = Some(vec![]); labels.push("move=clear".to_string()); } else { let area = resolve_project_area(store, move_raw, "--move", "an area or clear")?; update.area_ids = Some(vec![area]); labels.push(format!("move={move_raw}")); } }
let mut current_tags = project.tags.clone(); if let Some(add_tags) = &args.tag_delta.add_tags { let (ids, err) = resolve_tag_ids(store, add_tags); if !err.is_empty() { return Err(err); } for id in ids { if !current_tags.iter().any(|t| t == &id) { current_tags.push(id); } } labels.push("add-tags".to_string()); } if let Some(remove_tags) = &args.tag_delta.remove_tags { let (ids, err) = resolve_removable_tag_ids(store, remove_tags); if !err.is_empty() { return Err(err); } current_tags.retain(|t| !ids.iter().any(|id| id == t)); labels.push("remove-tags".to_string()); } if args.tag_delta.add_tags.is_some() || args.tag_delta.remove_tags.is_some() { update.tag_ids = Some(current_tags); }
if update.is_empty() { return Err("No edit changes requested.".to_string()); }
update.modification_date = Some(Some(now));
Ok(ProjectsEditPlan { project, update, labels, })}
impl Command for ProjectsArgs { fn run_with_ctx( &self, cli: &Cli, out: &mut dyn std::io::Write, ctx: &mut dyn crate::cmd_ctx::CmdCtx, ) -> Result<()> { // `projects --detailed` and `projects list --detailed` show details // `projects --detailed list` does not // the subcommand's own flag wins over the one before it let effective_detailed = match self.command.as_ref() { None => self.detailed, Some(ProjectsSubcommand::List(la)) => la.detailed, _ => false, }; let effective_json = match self.command.as_ref() { None | Some(ProjectsSubcommand::List(_)) => cli.json, _ => false, };
match &self.command { None | Some(ProjectsSubcommand::List(_)) => { let store = Arc::new(cli.load_store()?); let today = ctx.today(); let projects = store.projects(Some(TaskStatus::Incomplete));
if effective_json { detailed_json_conflict(effective_json, effective_detailed)?; write_json(out, &build_tasks_json(&projects, &store, &today))?; return Ok(()); }
let mut by_area: BTreeMap<Option<ThingsId>, Vec<_>> = BTreeMap::new(); for p in &projects { by_area.entry(p.area.clone()).or_default().push(p.clone()); }
let mut id_scope = projects.iter().map(|p| p.uuid.clone()).collect::<Vec<_>>(); id_scope.extend(by_area.keys().flatten().cloned()); let id_prefix_len = store.unique_prefix_length(&id_scope);
let no_area = by_area.remove(&None).unwrap_or_default();
// areas in their own order, the one the app shows let mut area_entries: Vec<(ThingsId, Vec<_>)> = by_area .into_iter() .filter_map(|(k, v)| k.map(|uuid| (uuid, v))) .collect(); area_entries.sort_by_key(|(uuid, _)| { store .areas_by_uuid .get(uuid) .map(|a| a.index) .unwrap_or(i32::MAX) });
let area_groups = area_entries .into_iter() .map(|(area_uuid, area_projects)| ProjectsAreaGroup { area_title: store.resolve_area_title(&area_uuid), area_uuid, projects: area_projects, }) .collect::<Vec<_>>();
let mut ui = element! { ContextProvider(value: Context::owned(store.clone())) { ContextProvider(value: Context::owned(today)) { ProjectsView( projects_count: projects.len(), no_area_projects: no_area, area_groups, detailed: effective_detailed, id_prefix_len, ) } } }; let rendered = render_element_to_string(&mut ui, cli.no_color()); writeln!(out, "{}", rendered)?; } Some(ProjectsSubcommand::New(args)) => { let title = args.title.trim(); if title.is_empty() { bail!("Project title cannot be empty."); }
let store = cli.load_store()?; let now = ctx.now_timestamp(); let mut props = TaskProps { title: title.to_string(), notes: Some(task6_note(&args.notes)), item_type: TaskType::Project, status: TaskStatus::Incomplete, start_location: TaskStart::Anytime, sort_index: 0, conflict_overrides: Some(serde_json::json!({"_t": "oo", "sn": {}})), creation_date: Some(now), modification_date: Some(now), ..Default::default() };
if let Some(area_id) = &args.area { let area = resolve_project_area(&store, area_id, "--area", "an area") .map_err(anyhow::Error::msg)?; props.area_ids = vec![area]; }
if let Some(when_raw) = &args.when { let when = when_raw.trim().to_lowercase(); if when == "anytime" { props.start_location = TaskStart::Anytime; } else if when == "someday" { props.start_location = TaskStart::Someday; } else if when == "today" { let ts = ctx.today_timestamp(); props.start_location = TaskStart::Anytime; props.scheduled_date = Some(ts); props.today_index_reference = Some(ts); } else { let Ok(day) = parse_day(when_raw.trim(), "--when") else { bail!( "Invalid --when: {} (expected anytime, someday, today or YYYY-MM-DD)", one_line(when_raw) ); }; let ts = day_timestamp(day); // a day that has come puts the project in Anytime // the app does the same props.start_location = if ts <= ctx.today_timestamp() { TaskStart::Anytime } else { TaskStart::Someday }; props.scheduled_date = Some(ts); props.today_index_reference = Some(ts); } }
if let Some(tags) = &args.tags { let (tag_ids, err) = resolve_tag_ids(&store, tags); if !err.is_empty() { bail!("{err}"); } props.tag_ids = tag_ids; }
if let Some(deadline) = &args.deadline_date { let day = parse_day(deadline, "--deadline").map_err(anyhow::Error::msg)?; props.deadline = Some(day_timestamp(day)); }
let uuid = ctx.next_id();
let mut changes = BTreeMap::new(); changes.insert(uuid.clone(), WireObject::create(EntityType::Task7, props)); ctx.commit_changes(changes) .with_context(|| "Failed to create project")?;
writeln!( out, "{} {} {}", colored(format!("{} Created", ICONS.done), &[GREEN], cli.no_color()), shown_title(title), colored(&uuid, &[DIM], cli.no_color()) )?; } Some(ProjectsSubcommand::Edit(args)) => { let store = cli.load_store()?; let plan = build_projects_edit_plan(args, &store, ctx.now_timestamp()) .map_err(anyhow::Error::msg)?;
let mut changes = BTreeMap::new(); changes.insert( plan.project.uuid.to_string(), WireObject::update(EntityType::Task7, plan.update.clone()), ); ctx.commit_changes(changes) .with_context(|| "Failed to edit project")?;
let title = plan.update.title.as_deref().unwrap_or(&plan.project.title); writeln!( out, "{} {} {} {}", colored(format!("{} Edited", ICONS.done), &[GREEN], cli.no_color()), shown_title(title), colored(&plan.project.uuid, &[DIM], cli.no_color()), colored( format!("({})", plan.labels.join(", ")), &[DIM], cli.no_color() ) )?; } } Ok(()) }}
#[cfg(test)]mod tests { use serde_json::json;
use super::*; use crate::{ ids::ThingsId, store::{ThingsStore, fold_items}, wire::{ area::AreaProps, tags::TagProps, task::{TaskProps, TaskStart, TaskStatus, TaskType}, wire_object::{EntityType, WireItem, WireObject}, }, };
const NOW: f64 = 1_700_000_222.0; const PROJECT_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore { let mut item: WireItem = BTreeMap::new(); for (uuid, obj) in entries { item.insert(uuid, obj); } ThingsStore::from_raw_state(&fold_items([item])) }
fn project(uuid: &str, title: &str, tags: Vec<&str>) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Task6, TaskProps { title: title.to_string(), item_type: TaskType::Project, status: TaskStatus::Incomplete, start_location: TaskStart::Anytime, sort_index: 0, tag_ids: tags .iter() .map(|t| { t.parse::<ThingsId>() .expect("test tag id should parse as ThingsId") }) .collect(), creation_date: Some(1.0), modification_date: Some(1.0), ..Default::default() }, ), ) }
fn area(uuid: &str, title: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Area3, AreaProps { title: title.to_string(), sort_index: 0, ..Default::default() }, ), ) }
fn tag(uuid: &str, title: &str) -> (String, WireObject) { ( uuid.to_string(), WireObject::create( EntityType::Tag4, TagProps { title: title.to_string(), sort_index: 0, ..Default::default() }, ), ) }
#[test] fn projects_edit_payload_variants() { let target_area_uuid = "JFdhhhp37fpryAKu8UXwzK"; let store = build_store(vec![ project(PROJECT_UUID, "Roadmap", vec![]), area(target_area_uuid, "Personal"), ]);
let title_plan = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: Some("Roadmap v2".to_string()), move_target: None, notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, }, &store, NOW, ) .expect("title plan"); let p = serde_json::to_value(&title_plan.update).expect("patch"); assert_eq!(p.get("tt"), Some(&json!("Roadmap v2"))); assert_eq!(p.get("md"), Some(&json!(NOW)));
let clear_plan = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: None, move_target: Some("clear".parse().expect("id")), notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, }, &store, NOW, ) .expect("clear plan"); assert_eq!( serde_json::to_value(&clear_plan.update) .expect("patch") .get("ar"), Some(&json!([])) );
let move_plan = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: None, move_target: Some(target_area_uuid.parse().expect("id")), notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, }, &store, NOW, ) .expect("move area plan"); assert_eq!( serde_json::to_value(&move_plan.update) .expect("patch") .get("ar"), Some(&json!([target_area_uuid])) ); }
#[test] fn projects_edit_tags_and_errors() { let tag1 = "WukwpDdL5Z88nX3okGMKTC"; let tag2 = "JiqwiDaS3CAyjCmHihBDnB"; let store = build_store(vec![ project(PROJECT_UUID, "Roadmap", vec![tag1, tag2]), tag(tag1, "Work"), tag(tag2, "Focus"), ]);
let remove_plan = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: None, move_target: None, notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: Some("Work".to_string()), }, }, &store, NOW, ) .expect("remove tags"); assert_eq!( serde_json::to_value(&remove_plan.update) .expect("patch") .get("tg"), Some(&json!([tag2])) );
let no_change = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: None, move_target: None, notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, }, &store, NOW, ) .expect_err("no changes"); assert_eq!(no_change, "No edit changes requested.");
let inbox = build_projects_edit_plan( &ProjectsEditArgs { project_id: PROJECT_UUID.parse().expect("id"), title: None, move_target: Some("inbox".parse().expect("id")), notes: None, tag_delta: TagDeltaArgs { add_tags: None, remove_tags: None, }, }, &store, NOW, ) .expect_err("cannot move inbox"); assert_eq!(inbox, "Projects cannot be moved to Inbox."); }}