Skip to main content

things3_cloud/commands/
reorder.rs

1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{colored, DIM, GREEN, ICONS};
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    /// Item UUID (or unique UUID prefix)
16    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::{fold_items, ThingsStore};
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; // 2023-11-14 00:00:00 UTC (midnight)
334
335    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}