Skip to main content

things3_cloud/commands/
reorder.rs

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    /// Item UUID (or unique UUID prefix)
21    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; // 2023-11-14 00:00:00 UTC (midnight)
342
343    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}