Skip to main content

things3_cloud/commands/
areas.rs

1use crate::app::Cli;
2use crate::commands::{Command, TagDeltaArgs};
3use crate::common::{colored, id_prefix, resolve_tag_ids, BOLD, DIM, GREEN, ICONS, MAGENTA};
4use crate::wire::area::{AreaPatch, AreaProps};
5use crate::wire::wire_object::{EntityType, WireObject};
6use anyhow::Result;
7use clap::{Args, Subcommand};
8use serde_json::json;
9use std::collections::BTreeMap;
10
11#[derive(Debug, Subcommand)]
12pub enum AreasSubcommand {
13    #[command(about = "Show all areas")]
14    List(AreasListArgs),
15    #[command(about = "Create a new area")]
16    New(AreasNewArgs),
17    #[command(about = "Edit an area title or tags")]
18    Edit(AreasEditArgs),
19}
20
21#[derive(Debug, Args)]
22#[command(about = "Show or create areas")]
23pub struct AreasArgs {
24    #[command(subcommand)]
25    pub command: Option<AreasSubcommand>,
26}
27
28#[derive(Debug, Default, Args)]
29pub struct AreasListArgs {}
30
31#[derive(Debug, Args)]
32pub struct AreasNewArgs {
33    /// Area title
34    pub title: String,
35    #[arg(long, help = "Comma-separated tags (titles or UUID prefixes)")]
36    pub tags: Option<String>,
37}
38
39#[derive(Debug, Args)]
40pub struct AreasEditArgs {
41    /// Area UUID (or unique UUID prefix)
42    pub area_id: String,
43    #[arg(long, help = "Replace title")]
44    pub title: Option<String>,
45    #[command(flatten)]
46    pub tag_delta: TagDeltaArgs,
47}
48
49#[derive(Debug, Clone)]
50struct AreasEditPlan {
51    area: crate::store::Area,
52    update: AreaPatch,
53    labels: Vec<String>,
54}
55
56fn build_areas_edit_plan(
57    args: &AreasEditArgs,
58    store: &crate::store::ThingsStore,
59    now: f64,
60) -> std::result::Result<AreasEditPlan, String> {
61    let (area_opt, err, _) = store.resolve_area_identifier(&args.area_id);
62    let Some(area) = area_opt else {
63        return Err(err);
64    };
65
66    let mut update = AreaPatch::default();
67    let mut labels = Vec::new();
68
69    if let Some(title) = &args.title {
70        let title = title.trim();
71        if title.is_empty() {
72            return Err("Area title cannot be empty.".to_string());
73        }
74        update.title = Some(title.to_string());
75        labels.push("title".to_string());
76    }
77
78    let mut current_tags = area.tags.clone();
79    if let Some(add_tags) = &args.tag_delta.add_tags {
80        let (ids, err) = resolve_tag_ids(store, add_tags);
81        if !err.is_empty() {
82            return Err(err);
83        }
84        for id in ids {
85            if !current_tags.iter().any(|t| t == &id) {
86                current_tags.push(id);
87            }
88        }
89        labels.push("add-tags".to_string());
90    }
91    if let Some(remove_tags) = &args.tag_delta.remove_tags {
92        let (ids, err) = resolve_tag_ids(store, remove_tags);
93        if !err.is_empty() {
94            return Err(err);
95        }
96        current_tags.retain(|t| !ids.iter().any(|id| id == t));
97        labels.push("remove-tags".to_string());
98    }
99    if args.tag_delta.add_tags.is_some() || args.tag_delta.remove_tags.is_some() {
100        update.tag_ids = Some(current_tags);
101    }
102
103    if update.is_empty() {
104        return Err("No edit changes requested.".to_string());
105    }
106
107    update.modification_date = Some(now);
108
109    Ok(AreasEditPlan {
110        area,
111        update,
112        labels,
113    })
114}
115
116impl Command for AreasArgs {
117    fn run_with_ctx(
118        &self,
119        cli: &Cli,
120        out: &mut dyn std::io::Write,
121        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
122    ) -> Result<()> {
123        match self
124            .command
125            .as_ref()
126            .unwrap_or(&AreasSubcommand::List(AreasListArgs::default()))
127        {
128            AreasSubcommand::List(_) => {
129                let store = cli.load_store()?;
130                let areas = store.areas();
131                if areas.is_empty() {
132                    writeln!(out, "{}", colored("No areas.", &[DIM], cli.no_color))?;
133                    return Ok(());
134                }
135
136                writeln!(
137                    out,
138                    "{}",
139                    colored(
140                        &format!("{} Areas  ({})", ICONS.area, areas.len()),
141                        &[BOLD, MAGENTA],
142                        cli.no_color,
143                    )
144                )?;
145                writeln!(out)?;
146
147                let id_prefix_len = store.unique_prefix_length(
148                    &areas.iter().map(|a| a.uuid.clone()).collect::<Vec<_>>(),
149                );
150                for area in areas {
151                    let tags = if area.tags.is_empty() {
152                        String::new()
153                    } else {
154                        let names = area
155                            .tags
156                            .iter()
157                            .map(|t| store.resolve_tag_title(t))
158                            .collect::<Vec<_>>()
159                            .join(", ");
160                        format!("  {}", colored(&format!("[{names}]"), &[DIM], cli.no_color))
161                    };
162                    writeln!(
163                        out,
164                        "  {} {} {}{}",
165                        id_prefix(&area.uuid, id_prefix_len, cli.no_color),
166                        colored(ICONS.area, &[DIM], cli.no_color),
167                        area.title,
168                        tags
169                    )?;
170                }
171            }
172            AreasSubcommand::New(args) => {
173                let title = args.title.trim();
174                if title.is_empty() {
175                    eprintln!("Area title cannot be empty.");
176                    return Ok(());
177                }
178
179                let store = cli.load_store()?;
180                let mut props = AreaProps {
181                    title: title.to_string(),
182                    sort_index: 0,
183                    conflict_overrides: Some(json!({"_t":"oo","sn":{}})),
184                    ..Default::default()
185                };
186
187                if let Some(tags) = &args.tags {
188                    let (tag_ids, err) = resolve_tag_ids(&store, tags);
189                    if !err.is_empty() {
190                        eprintln!("{err}");
191                        return Ok(());
192                    }
193                    props.tag_ids = tag_ids;
194                }
195
196                let uuid = ctx.next_id();
197                let mut changes = BTreeMap::new();
198                changes.insert(uuid.clone(), WireObject::create(EntityType::Area3, props));
199                if let Err(e) = ctx.commit_changes(changes, None) {
200                    eprintln!("Failed to create area: {e}");
201                    return Ok(());
202                }
203
204                writeln!(
205                    out,
206                    "{} {}  {}",
207                    colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
208                    title,
209                    colored(&uuid, &[DIM], cli.no_color)
210                )?;
211            }
212            AreasSubcommand::Edit(args) => {
213                let store = cli.load_store()?;
214                let plan = match build_areas_edit_plan(args, &store, ctx.now_timestamp()) {
215                    Ok(plan) => plan,
216                    Err(err) => {
217                        eprintln!("{err}");
218                        return Ok(());
219                    }
220                };
221
222                let mut changes = BTreeMap::new();
223                changes.insert(
224                    plan.area.uuid.to_string(),
225                    WireObject::update(EntityType::Area3, plan.update.clone()),
226                );
227                if let Err(e) = ctx.commit_changes(changes, None) {
228                    eprintln!("Failed to edit area: {e}");
229                    return Ok(());
230                }
231
232                let title = plan.update.title.as_deref().unwrap_or(&plan.area.title);
233                writeln!(
234                    out,
235                    "{} {}  {} {}",
236                    colored(&format!("{} Edited", ICONS.done), &[GREEN], cli.no_color),
237                    title,
238                    colored(&plan.area.uuid, &[DIM], cli.no_color),
239                    colored(
240                        &format!("({})", plan.labels.join(", ")),
241                        &[DIM],
242                        cli.no_color
243                    )
244                )?;
245            }
246        }
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::ids::ThingsId;
255    use crate::store::{fold_items, ThingsStore};
256    use crate::wire::area::AreaProps;
257    use crate::wire::tags::TagProps;
258    use crate::wire::wire_object::WireItem;
259    use crate::wire::wire_object::{EntityType, WireObject};
260
261    const NOW: f64 = 1_700_000_222.0;
262    const AREA_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf";
263
264    fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
265        let mut item: WireItem = BTreeMap::new();
266        for (uuid, obj) in entries {
267            item.insert(uuid, obj);
268        }
269        ThingsStore::from_raw_state(&fold_items([item]))
270    }
271
272    fn area(uuid: &str, title: &str, tags: Vec<&str>) -> (String, WireObject) {
273        (
274            uuid.to_string(),
275            WireObject::create(
276                EntityType::Area3,
277                AreaProps {
278                    title: title.to_string(),
279                    tag_ids: tags.iter().map(|t| ThingsId::from(*t)).collect(),
280                    sort_index: 0,
281                    ..Default::default()
282                },
283            ),
284        )
285    }
286
287    fn tag(uuid: &str, title: &str) -> (String, WireObject) {
288        (
289            uuid.to_string(),
290            WireObject::create(
291                EntityType::Tag4,
292                TagProps {
293                    title: title.to_string(),
294                    sort_index: 0,
295                    ..Default::default()
296                },
297            ),
298        )
299    }
300
301    #[test]
302    fn areas_edit_payload_and_errors() {
303        let tag1 = "WukwpDdL5Z88nX3okGMKTC";
304        let tag2 = "JiqwiDaS3CAyjCmHihBDnB";
305        let store = build_store(vec![
306            area(AREA_UUID, "Home", vec![tag1, tag2]),
307            tag(tag1, "Work"),
308            tag(tag2, "Focus"),
309        ]);
310
311        let title = build_areas_edit_plan(
312            &AreasEditArgs {
313                area_id: AREA_UUID.to_string(),
314                title: Some("New Name".to_string()),
315                tag_delta: TagDeltaArgs {
316                    add_tags: None,
317                    remove_tags: None,
318                },
319            },
320            &store,
321            NOW,
322        )
323        .expect("title plan");
324        let p = title.update.into_properties();
325        assert_eq!(p.get("tt"), Some(&json!("New Name")));
326        assert_eq!(p.get("md"), Some(&json!(NOW)));
327
328        let remove = build_areas_edit_plan(
329            &AreasEditArgs {
330                area_id: AREA_UUID.to_string(),
331                title: None,
332                tag_delta: TagDeltaArgs {
333                    add_tags: None,
334                    remove_tags: Some("Work".to_string()),
335                },
336            },
337            &store,
338            NOW,
339        )
340        .expect("remove tag");
341        assert_eq!(
342            remove.update.into_properties().get("tg"),
343            Some(&json!([tag2]))
344        );
345
346        let no_change = build_areas_edit_plan(
347            &AreasEditArgs {
348                area_id: AREA_UUID.to_string(),
349                title: None,
350                tag_delta: TagDeltaArgs {
351                    add_tags: None,
352                    remove_tags: None,
353                },
354            },
355            &store,
356            NOW,
357        )
358        .expect_err("no change");
359        assert_eq!(no_change, "No edit changes requested.");
360
361        let empty_title = build_areas_edit_plan(
362            &AreasEditArgs {
363                area_id: AREA_UUID.to_string(),
364                title: Some("".to_string()),
365                tag_delta: TagDeltaArgs {
366                    add_tags: None,
367                    remove_tags: None,
368                },
369            },
370            &store,
371            NOW,
372        )
373        .expect_err("empty title");
374        assert_eq!(empty_title, "Area title cannot be empty.");
375    }
376}