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