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