Skip to main content

things3_cloud/commands/
areas.rs

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