Skip to main content

things3_cloud/commands/
areas.rs

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