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 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 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}