1use std::{cmp::Ordering, collections::BTreeMap};
2
3use anyhow::Result;
4use chrono::{TimeZone, Utc};
5use clap::Args;
6
7use crate::{
8 app::Cli,
9 commands::Command,
10 common::{DIM, GREEN, ICONS, colored},
11 wire::{
12 task::{TaskPatch, TaskStart, TaskStatus},
13 wire_object::{EntityType, WireObject},
14 },
15};
16
17#[derive(Debug, Args)]
18#[command(about = "Reorder item relative to another item")]
19pub struct ReorderArgs {
20 pub item_id: String,
22 #[arg(long, help = "Anchor item UUID/prefix to place before")]
23 pub before_id: Option<String>,
24 #[arg(long, help = "Anchor item UUID/prefix to place after")]
25 pub after_id: Option<String>,
26}
27
28#[derive(Debug, Clone)]
29struct ReorderCommit {
30 changes: BTreeMap<String, WireObject>,
31 ancestor_index: Option<i64>,
32}
33
34#[derive(Debug, Clone)]
35struct ReorderPlan {
36 item: crate::store::Task,
37 commits: Vec<ReorderCommit>,
38 reorder_label: String,
39}
40
41fn build_reorder_plan(
42 args: &ReorderArgs,
43 store: &crate::store::ThingsStore,
44 now: f64,
45 today_ts: i64,
46 initial_ancestor_index: Option<i64>,
47) -> std::result::Result<ReorderPlan, String> {
48 let today = Utc
49 .timestamp_opt(today_ts, 0)
50 .single()
51 .unwrap_or_else(Utc::now)
52 .date_naive()
53 .and_hms_opt(0, 0, 0)
54 .map(|d| Utc.from_utc_datetime(&d))
55 .unwrap_or_else(Utc::now);
56 let (item_opt, err, _) = store.resolve_task_identifier(&args.item_id);
57 let Some(item) = item_opt else {
58 return Err(err);
59 };
60
61 let anchor_id = args
62 .before_id
63 .as_ref()
64 .or(args.after_id.as_ref())
65 .cloned()
66 .unwrap_or_default();
67 let (anchor_opt, err, _) = store.resolve_task_identifier(&anchor_id);
68 let Some(anchor) = anchor_opt else {
69 return Err(err);
70 };
71
72 if item.uuid == anchor.uuid {
73 return Err("Cannot reorder an item relative to itself.".to_string());
74 }
75
76 let is_today_orderable = |task: &crate::store::Task| {
77 task.start == TaskStart::Anytime && (task.is_today(&today) || task.evening)
78 };
79 let is_today_reorder = is_today_orderable(&item) && is_today_orderable(&anchor);
80
81 if is_today_reorder {
82 let anchor_tir = anchor
83 .today_index_reference
84 .or_else(|| anchor.start_date.map(|d| d.timestamp()))
85 .unwrap_or(today_ts);
86 let new_ti = if args.before_id.is_some() {
87 anchor.today_index - 1
88 } else {
89 anchor.today_index + 1
90 };
91
92 let sb = if item.evening != anchor.evening {
93 Some(if anchor.evening { 1 } else { 0 })
94 } else {
95 None
96 };
97 let mut changes = BTreeMap::new();
98 changes.insert(
99 item.uuid.to_string(),
100 WireObject::update(
101 EntityType::from(item.entity.clone()),
102 TaskPatch {
103 today_index_reference: Some(Some(anchor_tir)),
104 today_sort_index: Some(new_ti),
105 evening_bit: sb,
106 modification_date: Some(now),
107 ..Default::default()
108 },
109 ),
110 );
111
112 let reorder_label = if args.before_id.is_some() {
113 format!(
114 "(before={}, today_ref={}, today_index={})",
115 anchor.title, anchor_tir, new_ti
116 )
117 } else {
118 format!(
119 "(after={}, today_ref={}, today_index={})",
120 anchor.title, anchor_tir, new_ti
121 )
122 };
123
124 return Ok(ReorderPlan {
125 item,
126 commits: vec![ReorderCommit {
127 changes,
128 ancestor_index: initial_ancestor_index,
129 }],
130 reorder_label,
131 });
132 }
133
134 let bucket = |task: &crate::store::Task| -> Vec<String> {
135 if task.is_heading() {
136 return vec![
137 "heading".to_string(),
138 task.project
139 .clone()
140 .map(|v| v.to_string())
141 .unwrap_or_default(),
142 ];
143 }
144 if task.is_project() {
145 return vec![
146 "project".to_string(),
147 task.area.clone().map(|v| v.to_string()).unwrap_or_default(),
148 ];
149 }
150 if let Some(project_uuid) = store.effective_project_uuid(task) {
151 return vec![
152 "task-project".to_string(),
153 project_uuid.to_string(),
154 task.action_group
155 .clone()
156 .map(|v| v.to_string())
157 .unwrap_or_default(),
158 ];
159 }
160 if let Some(area_uuid) = store.effective_area_uuid(task) {
161 return vec![
162 "task-area".to_string(),
163 area_uuid.to_string(),
164 i32::from(task.start).to_string(),
165 ];
166 }
167 vec!["task-root".to_string(), i32::from(task.start).to_string()]
168 };
169
170 let item_bucket = bucket(&item);
171 let anchor_bucket = bucket(&anchor);
172 if item_bucket != anchor_bucket {
173 return Err("Cannot reorder across different containers/lists.".to_string());
174 }
175
176 let mut siblings = store
177 .tasks_by_uuid
178 .values()
179 .filter(|t| !t.trashed && t.status == TaskStatus::Incomplete && bucket(t) == item_bucket)
180 .cloned()
181 .collect::<Vec<_>>();
182 siblings.sort_by(|a, b| match a.index.cmp(&b.index) {
183 Ordering::Equal => a.uuid.cmp(&b.uuid),
184 other => other,
185 });
186
187 let by_uuid = siblings
188 .iter()
189 .map(|t| (t.uuid.clone(), t.clone()))
190 .collect::<BTreeMap<_, _>>();
191 if !by_uuid.contains_key(&item.uuid) || !by_uuid.contains_key(&anchor.uuid) {
192 return Err("Cannot reorder item in the selected list.".to_string());
193 }
194
195 let mut order = siblings
196 .into_iter()
197 .filter(|t| t.uuid != item.uuid)
198 .collect::<Vec<_>>();
199 let anchor_pos = order.iter().position(|t| t.uuid == anchor.uuid);
200 let Some(anchor_pos) = anchor_pos else {
201 return Err("Anchor not found in reorder list.".to_string());
202 };
203 let insert_at = if args.before_id.is_some() {
204 anchor_pos
205 } else {
206 anchor_pos + 1
207 };
208 order.insert(insert_at, item.clone());
209
210 let moved_pos = order.iter().position(|t| t.uuid == item.uuid).unwrap_or(0);
211 let prev_ix = if moved_pos > 0 {
212 Some(order[moved_pos - 1].index)
213 } else {
214 None
215 };
216 let next_ix = if moved_pos + 1 < order.len() {
217 Some(order[moved_pos + 1].index)
218 } else {
219 None
220 };
221
222 let mut index_updates: Vec<(String, i32, String)> = Vec::new();
223 let new_index = if prev_ix.is_none() && next_ix.is_none() {
224 0
225 } else if prev_ix.is_none() {
226 next_ix.unwrap_or(0) - 1
227 } else if next_ix.is_none() {
228 prev_ix.unwrap_or(0) + 1
229 } else if prev_ix.unwrap_or(0) + 1 < next_ix.unwrap_or(0) {
230 (prev_ix.unwrap_or(0) + next_ix.unwrap_or(0)) / 2
231 } else {
232 let stride = 1024;
233 for (idx, task) in order.iter().enumerate() {
234 let target_ix = (idx as i32 + 1) * stride;
235 if task.index != target_ix {
236 index_updates.push((task.uuid.to_string(), target_ix, task.entity.clone()));
237 }
238 }
239 index_updates
240 .iter()
241 .find(|(uid, _, _)| uid == &item.uuid.to_string())
242 .map(|(_, ix, _)| *ix)
243 .unwrap_or(item.index)
244 };
245
246 if index_updates.is_empty() && new_index != item.index {
247 index_updates.push((item.uuid.to_string(), new_index, item.entity.clone()));
248 }
249
250 let mut commits = Vec::new();
251 let mut ancestor = initial_ancestor_index;
252 for (task_uuid, task_index, task_entity) in index_updates {
253 let mut changes = BTreeMap::new();
254 changes.insert(
255 task_uuid,
256 WireObject::update(
257 EntityType::from(task_entity),
258 TaskPatch {
259 sort_index: Some(task_index),
260 modification_date: Some(now),
261 ..Default::default()
262 },
263 ),
264 );
265 commits.push(ReorderCommit {
266 changes,
267 ancestor_index: ancestor,
268 });
269 ancestor = ancestor.map(|v| v + 1).or(Some(1));
270 }
271
272 let reorder_label = if args.before_id.is_some() {
273 format!("(before={}, index={})", anchor.title, new_index)
274 } else {
275 format!("(after={}, index={})", anchor.title, new_index)
276 };
277
278 Ok(ReorderPlan {
279 item,
280 commits,
281 reorder_label,
282 })
283}
284
285impl Command for ReorderArgs {
286 fn run_with_ctx(
287 &self,
288 cli: &Cli,
289 out: &mut dyn std::io::Write,
290 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
291 ) -> Result<()> {
292 let store = cli.load_store()?;
293 let plan = match build_reorder_plan(
294 self,
295 &store,
296 ctx.now_timestamp(),
297 ctx.today_timestamp(),
298 None,
299 ) {
300 Ok(plan) => plan,
301 Err(err) => {
302 eprintln!("{err}");
303 return Ok(());
304 }
305 };
306
307 for commit in plan.commits {
308 if let Err(e) = ctx.commit_changes(commit.changes, commit.ancestor_index) {
309 eprintln!("Failed to reorder item: {e}");
310 return Ok(());
311 }
312 }
313
314 writeln!(
315 out,
316 "{} {} {} {}",
317 colored(&format!("{} Reordered", ICONS.done), &[GREEN], cli.no_color),
318 plan.item.title,
319 colored(&plan.item.uuid, &[DIM], cli.no_color),
320 colored(&plan.reorder_label, &[DIM], cli.no_color)
321 )?;
322
323 Ok(())
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use serde_json::json;
330
331 use super::*;
332 use crate::{
333 store::{ThingsStore, fold_items},
334 wire::task::{TaskProps, TaskStart, TaskStatus, TaskType},
335 };
336
337 const NOW: f64 = 1_700_000_444.0;
338 const TASK_A: &str = "A7h5eCi24RvAWKC3Hv3muf";
339 const TASK_B: &str = "KGvAPpMrzHAKMdgMiERP1V";
340 const TASK_C: &str = "MpkEei6ybkFS2n6SXvwfLf";
341 const TODAY: i64 = 1_699_920_000; fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
344 let mut item = BTreeMap::new();
345 for (uuid, obj) in entries {
346 item.insert(uuid, obj);
347 }
348 ThingsStore::from_raw_state(&fold_items([item]))
349 }
350
351 #[allow(clippy::too_many_arguments)]
352 fn task(
353 uuid: &str,
354 title: &str,
355 st: i32,
356 ss: i32,
357 ix: i32,
358 sr: Option<i64>,
359 tir: Option<i64>,
360 ti: i32,
361 ) -> (String, WireObject) {
362 (
363 uuid.to_string(),
364 WireObject::create(
365 EntityType::Task6,
366 TaskProps {
367 title: title.to_string(),
368 item_type: TaskType::Todo,
369 status: TaskStatus::from(ss),
370 start_location: TaskStart::from(st),
371 sort_index: ix,
372 scheduled_date: sr,
373 today_index_reference: tir,
374 today_sort_index: ti,
375 creation_date: Some(1.0),
376 modification_date: Some(1.0),
377 ..Default::default()
378 },
379 ),
380 )
381 }
382
383 #[test]
384 fn reorder_before_after_and_today_payloads() {
385 let store = build_store(vec![
386 task(TASK_A, "A", 0, 0, 1024, None, None, 0),
387 task(TASK_B, "B", 0, 0, 2048, None, None, 0),
388 task(TASK_C, "C", 0, 0, 3072, None, None, 0),
389 ]);
390
391 let before = build_reorder_plan(
392 &ReorderArgs {
393 item_id: TASK_C.to_string(),
394 before_id: Some(TASK_B.to_string()),
395 after_id: None,
396 },
397 &store,
398 NOW,
399 TODAY,
400 None,
401 )
402 .expect("before plan");
403 assert_eq!(before.commits.len(), 1);
404 assert_eq!(
405 serde_json::to_value(before.commits[0].changes.clone()).expect("to value"),
406 json!({ TASK_C: {"t":1,"e":"Task6","p":{"ix":1536,"md":NOW}} })
407 );
408
409 let store_today = build_store(vec![
410 task(TASK_A, "A", 1, 0, 100, Some(TODAY), Some(TODAY), 10),
411 task(TASK_B, "B", 1, 0, 200, Some(TODAY), Some(TODAY), 20),
412 ]);
413 let today_plan = build_reorder_plan(
414 &ReorderArgs {
415 item_id: TASK_A.to_string(),
416 before_id: None,
417 after_id: Some(TASK_B.to_string()),
418 },
419 &store_today,
420 NOW,
421 TODAY,
422 None,
423 )
424 .expect("today plan");
425 assert_eq!(
426 serde_json::to_value(today_plan.commits[0].changes.clone()).expect("to value"),
427 json!({ TASK_A: {"t":1,"e":"Task6","p":{"tir":TODAY,"ti":21,"md":NOW}} })
428 );
429 }
430
431 #[test]
432 fn reorder_rebalance_and_errors() {
433 let store = build_store(vec![
434 task(TASK_A, "A", 0, 0, 1024, None, None, 0),
435 task(TASK_B, "B", 0, 0, 1025, None, None, 0),
436 task(TASK_C, "C", 0, 0, 1026, None, None, 0),
437 ]);
438 let rebalance = build_reorder_plan(
439 &ReorderArgs {
440 item_id: TASK_C.to_string(),
441 before_id: None,
442 after_id: Some(TASK_A.to_string()),
443 },
444 &store,
445 NOW,
446 TODAY,
447 Some(50),
448 )
449 .expect("rebalance");
450 assert_eq!(rebalance.commits.len(), 2);
451 assert_eq!(rebalance.commits[0].ancestor_index, Some(50));
452 assert_eq!(rebalance.commits[1].ancestor_index, Some(51));
453
454 let err = build_reorder_plan(
455 &ReorderArgs {
456 item_id: TASK_A.to_string(),
457 before_id: Some(TASK_A.to_string()),
458 after_id: None,
459 },
460 &store,
461 NOW,
462 TODAY,
463 None,
464 )
465 .expect_err("self reorder");
466 assert_eq!(err, "Cannot reorder an item relative to itself.");
467 }
468}