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