Skip to main content

things3_cloud/store/
mod.rs

1mod entities;
2mod state;
3
4pub use entities::{
5    Area, AreaStateProps, ChecklistItem, ChecklistItemStateProps, ProjectProgress, StateObject,
6    StateProperties, Tag, TagStateProps, Task, TaskStateProps,
7};
8pub use state::{RawState, fold_item, fold_items};
9
10use crate::ids::matching::{prefix_matches, shortest_unique_prefixes};
11use crate::ids::ThingsId;
12use crate::wire::task::{TaskStart, TaskStatus, TaskType};
13use crate::wire::wire_object::EntityType;
14use chrono::{DateTime, FixedOffset, Local, TimeZone, Utc};
15use std::cmp::Reverse;
16use std::collections::{HashMap, HashSet};
17
18#[derive(Debug, Default)]
19pub struct ThingsStore {
20    pub tasks_by_uuid: HashMap<ThingsId, Task>,
21    pub areas_by_uuid: HashMap<ThingsId, Area>,
22    pub tags_by_uuid: HashMap<ThingsId, Tag>,
23    pub tags_by_title: HashMap<String, ThingsId>,
24    pub project_progress_by_uuid: HashMap<ThingsId, ProjectProgress>,
25    pub short_ids: HashMap<ThingsId, String>,
26    pub markable_ids: HashSet<ThingsId>,
27    pub markable_ids_sorted: Vec<ThingsId>,
28    pub area_ids_sorted: Vec<ThingsId>,
29    pub task_ids_sorted: Vec<ThingsId>,
30}
31
32fn ts_to_dt(ts: Option<f64>) -> Option<DateTime<Utc>> {
33    let ts = ts?;
34    let mut secs = ts.floor() as i64;
35    let mut nanos = ((ts - secs as f64) * 1_000_000_000_f64).round() as u32;
36    if nanos >= 1_000_000_000 {
37        secs += 1;
38        nanos = 0;
39    }
40    Utc.timestamp_opt(secs, nanos).single()
41}
42
43fn fixed_local_offset() -> FixedOffset {
44    let seconds = Local::now().offset().local_minus_utc();
45    FixedOffset::east_opt(seconds).unwrap_or_else(|| FixedOffset::east_opt(0).expect("UTC offset"))
46}
47
48impl ThingsStore {
49    pub fn from_raw_state(raw_state: &RawState) -> Self {
50        let mut store = Self::default();
51        store.build(raw_state);
52        store.build_project_progress_index();
53        store.short_ids = shortest_unique_prefixes(&store.short_id_domain(raw_state));
54        store.build_mark_indexes();
55        store.area_ids_sorted = store.areas_by_uuid.keys().cloned().collect();
56        store.area_ids_sorted.sort();
57        store.task_ids_sorted = store.tasks_by_uuid.keys().cloned().collect();
58        store.task_ids_sorted.sort();
59        store
60    }
61
62    fn short_id_domain(&self, raw_state: &RawState) -> Vec<ThingsId> {
63        let mut ids = Vec::new();
64        for (uuid, obj) in raw_state {
65            match obj.entity_type.as_ref() {
66                Some(EntityType::Tombstone | EntityType::Tombstone2) => continue,
67                _ => {}
68            }
69
70            ids.push(uuid.clone());
71        }
72        ids
73    }
74
75    fn build_mark_indexes(&mut self) {
76        let markable: Vec<&Task> = self
77            .tasks_by_uuid
78            .values()
79            .filter(|task| !task.trashed && !task.is_heading() && task.entity == "Task6")
80            .collect();
81
82        self.markable_ids = markable.iter().map(|t| t.uuid.clone()).collect();
83        self.markable_ids_sorted = self.markable_ids.iter().cloned().collect();
84        self.markable_ids_sorted.sort();
85    }
86
87    fn build_project_progress_index(&mut self) {
88        let mut totals: HashMap<ThingsId, i32> = HashMap::new();
89        let mut dones: HashMap<ThingsId, i32> = HashMap::new();
90
91        for task in self.tasks_by_uuid.values() {
92            if task.trashed || !task.is_todo() {
93                continue;
94            }
95
96            let Some(project_uuid) = self.effective_project_uuid(task) else {
97                continue;
98            };
99
100            *totals.entry(project_uuid.clone()).or_insert(0) += 1;
101            if task.is_completed() {
102                *dones.entry(project_uuid).or_insert(0) += 1;
103            }
104        }
105
106        self.project_progress_by_uuid = totals
107            .into_iter()
108            .map(|(project_uuid, total)| {
109                let done = *dones.get(&project_uuid).unwrap_or(&0);
110                (project_uuid, ProjectProgress { total, done })
111            })
112            .collect();
113    }
114
115    fn build(&mut self, raw_state: &RawState) {
116        let mut checklist_items: Vec<ChecklistItem> = Vec::new();
117
118        for (uuid, obj) in raw_state {
119            match obj.entity_type.as_ref() {
120                Some(EntityType::Task3 | EntityType::Task4 | EntityType::Task6) => {
121                    let entity = match obj.entity_type.as_ref() {
122                        Some(other) => String::from(other.clone()),
123                        None => "Task6".to_string(),
124                    };
125                    let StateProperties::Task(props) = &obj.properties else {
126                        continue;
127                    };
128                    let task = self.parse_task(uuid, props, &entity);
129                    self.tasks_by_uuid.insert(uuid.clone(), task);
130                }
131                Some(EntityType::Area2 | EntityType::Area3) => {
132                    let StateProperties::Area(props) = &obj.properties else {
133                        continue;
134                    };
135                    let area = self.parse_area(uuid, props);
136                    self.areas_by_uuid.insert(uuid.clone(), area);
137                }
138                Some(EntityType::Tag3 | EntityType::Tag4) => {
139                    let StateProperties::Tag(props) = &obj.properties else {
140                        continue;
141                    };
142                    let tag = self.parse_tag(uuid, props);
143                    if !tag.title.is_empty() {
144                        self.tags_by_title.insert(tag.title.clone(), tag.uuid.clone());
145                    }
146                    self.tags_by_uuid.insert(uuid.clone(), tag);
147                }
148                Some(EntityType::ChecklistItem | EntityType::ChecklistItem2 | EntityType::ChecklistItem3) => {
149                    if let StateProperties::ChecklistItem(props) = &obj.properties {
150                        checklist_items.push(self.parse_checklist_item(uuid, props));
151                    }
152                }
153                _ => {}
154            }
155        }
156
157        let mut by_task: HashMap<ThingsId, Vec<ChecklistItem>> = HashMap::new();
158        for item in checklist_items {
159            if self.tasks_by_uuid.contains_key(&item.task_uuid) {
160                by_task.entry(item.task_uuid.clone()).or_default().push(item);
161            }
162        }
163
164        for (task_uuid, items) in by_task.iter_mut() {
165            items.sort_by_key(|i| i.index);
166            if let Some(task) = self.tasks_by_uuid.get_mut(task_uuid) {
167                task.checklist_items = items.clone();
168            }
169        }
170    }
171
172    fn parse_task(&self, uuid: &ThingsId, p: &TaskStateProps, entity: &str) -> Task {
173        Task {
174            uuid: uuid.clone(),
175            title: p.title.clone(),
176            status: p.status,
177            start: p.start_location,
178            item_type: p.item_type,
179            entity: entity.to_string(),
180            notes: p.notes.clone(),
181            project: p.parent_project_ids.first().cloned(),
182            area: p.area_ids.first().cloned(),
183            action_group: p.action_group_ids.first().cloned(),
184            tags: p.tag_ids.clone(),
185            trashed: p.trashed,
186            deadline: ts_to_dt(p.deadline),
187            start_date: ts_to_dt(p.scheduled_date),
188            stop_date: ts_to_dt(p.stop_date),
189            creation_date: ts_to_dt(p.creation_date),
190            modification_date: ts_to_dt(p.modification_date),
191            index: p.sort_index,
192            today_index: p.today_sort_index,
193            today_index_reference: p.today_index_reference,
194            leaves_tombstone: p.leaves_tombstone,
195            instance_creation_paused: p.instance_creation_paused,
196            evening: p.evening_bit != 0,
197            recurrence_rule: p.recurrence_rule.clone(),
198            recurrence_templates: p.recurrence_template_ids.clone(),
199            checklist_items: Vec::new(),
200        }
201    }
202
203    fn parse_checklist_item(&self, uuid: &ThingsId, p: &ChecklistItemStateProps) -> ChecklistItem {
204        ChecklistItem {
205            uuid: uuid.clone(),
206            title: p.title.clone(),
207            task_uuid: p.task_ids.first().cloned().unwrap_or_default(),
208            status: p.status,
209            index: p.sort_index,
210        }
211    }
212
213    fn parse_area(&self, uuid: &ThingsId, p: &AreaStateProps) -> Area {
214        Area {
215            uuid: uuid.clone(),
216            title: p.title.clone(),
217            tags: p.tag_ids.clone(),
218            index: p.sort_index,
219        }
220    }
221
222    fn parse_tag(&self, uuid: &ThingsId, p: &TagStateProps) -> Tag {
223        Tag {
224            uuid: uuid.clone(),
225            title: p.title.clone(),
226            shortcut: p.shortcut.clone(),
227            index: p.sort_index,
228            parent_uuid: p.parent_ids.first().cloned(),
229        }
230    }
231
232    pub fn tasks(
233        &self,
234        status: Option<TaskStatus>,
235        trashed: Option<bool>,
236        item_type: Option<TaskType>,
237    ) -> Vec<Task> {
238        let mut out: Vec<Task> = self
239            .tasks_by_uuid
240            .values()
241            .filter(|task| {
242                if let Some(expect_trashed) = trashed
243                    && task.trashed != expect_trashed
244                {
245                    return false;
246                }
247                if let Some(expect_status) = status
248                    && task.status != expect_status
249                {
250                    return false;
251                }
252                if let Some(expect_type) = item_type
253                    && task.item_type != expect_type
254                {
255                    return false;
256                }
257                if task.is_heading() {
258                    return false;
259                }
260                true
261            })
262            .cloned()
263            .collect();
264        out.sort_by_key(|t| t.index);
265        out
266    }
267
268    pub fn today(&self, today: &DateTime<Utc>) -> Vec<Task> {
269        let mut out: Vec<Task> = self
270            .tasks_by_uuid
271            .values()
272            .filter(|t| {
273                !t.trashed
274                    && t.status == TaskStatus::Incomplete
275                    && !t.is_heading()
276                    && !t.is_project()
277                    && !t.title.trim().is_empty()
278                    && t.entity == "Task6"
279                    && t.is_today(today)
280            })
281            .cloned()
282            .collect();
283
284        out.sort_by_key(|task| {
285            if task.today_index == 0 {
286                let sr_ts = task.start_date.map(|d| d.timestamp()).unwrap_or(0);
287                (0i32, Reverse(sr_ts), Reverse(task.index))
288            } else {
289                (1i32, Reverse(task.today_index as i64), Reverse(task.index))
290            }
291        });
292        out
293    }
294
295    pub fn inbox(&self) -> Vec<Task> {
296        let mut out: Vec<Task> = self
297            .tasks_by_uuid
298            .values()
299            .filter(|t| {
300                !t.trashed
301                    && t.status == TaskStatus::Incomplete
302                    && t.start == TaskStart::Inbox
303                    && self.effective_project_uuid(t).is_none()
304                    && self.effective_area_uuid(t).is_none()
305                    && !t.is_project()
306                    && !t.is_heading()
307                    && !t.title.trim().is_empty()
308                    && t.creation_date.is_some()
309                    && t.entity == "Task6"
310            })
311            .cloned()
312            .collect();
313        out.sort_by_key(|t| t.index);
314        out
315    }
316
317    pub fn anytime(&self, today: &DateTime<Utc>) -> Vec<Task> {
318        let project_visible = |task: &Task, store: &ThingsStore| {
319            let Some(project_uuid) = store.effective_project_uuid(task) else {
320                return true;
321            };
322            let Some(project) = store.tasks_by_uuid.get(&project_uuid) else {
323                return true;
324            };
325            if project.trashed || project.status != TaskStatus::Incomplete {
326                return false;
327            }
328            if project.start == TaskStart::Someday {
329                return false;
330            }
331            if let Some(start_date) = project.start_date
332                && start_date > *today
333            {
334                return false;
335            }
336            true
337        };
338
339        let mut out: Vec<Task> = self
340            .tasks_by_uuid
341            .values()
342            .filter(|t| {
343                !t.trashed
344                    && t.status == TaskStatus::Incomplete
345                    && t.start == TaskStart::Anytime
346                    && !t.is_project()
347                    && !t.is_heading()
348                    && !t.title.trim().is_empty()
349                    && t.entity == "Task6"
350                    && (t.start_date.is_none() || t.start_date <= Some(*today))
351                    && project_visible(t, self)
352            })
353            .cloned()
354            .collect();
355        out.sort_by_key(|t| t.index);
356        out
357    }
358
359    pub fn someday(&self) -> Vec<Task> {
360        let mut out: Vec<Task> = self
361            .tasks_by_uuid
362            .values()
363            .filter(|t| {
364                !t.trashed
365                    && t.status == TaskStatus::Incomplete
366                    && t.start == TaskStart::Someday
367                    && !t.is_heading()
368                    && !t.title.trim().is_empty()
369                    && t.entity == "Task6"
370                    && !t.is_recurrence_template()
371                    && t.start_date.is_none()
372                    && (t.is_project() || self.effective_project_uuid(t).is_none())
373            })
374            .cloned()
375            .collect();
376        out.sort_by_key(|t| t.index);
377        out
378    }
379
380    pub fn logbook(
381        &self,
382        from_date: Option<DateTime<Local>>,
383        to_date: Option<DateTime<Local>>,
384    ) -> Vec<Task> {
385        let mut out: Vec<Task> = self
386            .tasks_by_uuid
387            .values()
388            .filter(|task| {
389                if task.trashed
390                    || !(task.status == TaskStatus::Completed || task.status == TaskStatus::Canceled)
391                {
392                    return false;
393                }
394                if task.is_heading() || task.entity != "Task6" {
395                    return false;
396                }
397                let Some(stop_date) = task.stop_date else {
398                    return false;
399                };
400
401                let stop_day = stop_date
402                    .with_timezone(&fixed_local_offset())
403                    .date_naive()
404                    .and_hms_opt(0, 0, 0)
405                    .and_then(|d| fixed_local_offset().from_local_datetime(&d).single())
406                    .map(|d| d.with_timezone(&Local));
407
408                if let Some(from_day) = from_date
409                    && let Some(sd) = stop_day
410                    && sd < from_day
411                {
412                    return false;
413                }
414                if let Some(to_day) = to_date
415                    && let Some(sd) = stop_day
416                    && sd > to_day
417                {
418                    return false;
419                }
420
421                true
422            })
423            .cloned()
424            .collect();
425
426        out.sort_by_key(|t| {
427            let stop_key = t
428                .stop_date
429                .map(|d| (d.timestamp(), d.timestamp_subsec_nanos()))
430                .unwrap_or((0, 0));
431            (Reverse(stop_key), Reverse(t.index), t.uuid.clone())
432        });
433        out
434    }
435
436    pub fn effective_project_uuid(&self, task: &Task) -> Option<ThingsId> {
437        if let Some(project) = &task.project {
438            return Some(project.clone());
439        }
440        if let Some(action_group) = &task.action_group
441            && let Some(heading) = self.tasks_by_uuid.get(action_group)
442            && let Some(project) = &heading.project
443        {
444            return Some(project.clone());
445        }
446        None
447    }
448
449    pub fn effective_area_uuid(&self, task: &Task) -> Option<ThingsId> {
450        if let Some(area) = &task.area {
451            return Some(area.clone());
452        }
453
454        if let Some(project_uuid) = self.effective_project_uuid(task)
455            && let Some(project) = self.tasks_by_uuid.get(&project_uuid)
456            && let Some(area) = &project.area
457        {
458            return Some(area.clone());
459        }
460
461        if let Some(action_group) = &task.action_group
462            && let Some(heading) = self.tasks_by_uuid.get(action_group)
463            && let Some(area) = &heading.area
464        {
465            return Some(area.clone());
466        }
467
468        None
469    }
470
471    pub fn projects(&self, status: Option<TaskStatus>) -> Vec<Task> {
472        let mut out: Vec<Task> = self
473            .tasks_by_uuid
474            .values()
475            .filter(|t| {
476                !t.trashed
477                    && t.is_project()
478                    && t.entity == "Task6"
479                    && status.map(|s| t.status == s).unwrap_or(true)
480            })
481            .cloned()
482            .collect();
483        out.sort_by_key(|t| t.index);
484        out
485    }
486
487    pub fn areas(&self) -> Vec<Area> {
488        let mut out: Vec<Area> = self.areas_by_uuid.values().cloned().collect();
489        out.sort_by_key(|a| a.index);
490        out
491    }
492
493    pub fn tags(&self) -> Vec<Tag> {
494        let mut out: Vec<Tag> = self
495            .tags_by_uuid
496            .values()
497            .filter(|t| !t.title.trim().is_empty())
498            .cloned()
499            .collect();
500        out.sort_by_key(|t| t.index);
501        out
502    }
503
504    pub fn get_task(&self, uuid: &str) -> Option<Task> {
505        uuid.parse::<ThingsId>()
506            .ok()
507            .and_then(|id| self.tasks_by_uuid.get(&id).cloned())
508    }
509
510    pub fn get_area(&self, uuid: &str) -> Option<Area> {
511        uuid.parse::<ThingsId>()
512            .ok()
513            .and_then(|id| self.areas_by_uuid.get(&id).cloned())
514    }
515
516    pub fn get_tag(&self, uuid: &str) -> Option<Tag> {
517        uuid.parse::<ThingsId>()
518            .ok()
519            .and_then(|id| self.tags_by_uuid.get(&id).cloned())
520    }
521
522    pub fn resolve_tag_title<T: ToString>(&self, uuid: T) -> String {
523        let raw = uuid.to_string();
524        raw.parse::<ThingsId>()
525            .ok()
526            .and_then(|id| self.tags_by_uuid.get(&id))
527            .filter(|t| !t.title.trim().is_empty())
528            .map(|t| t.title.clone())
529            .unwrap_or(raw)
530    }
531
532    pub fn resolve_area_title<T: ToString>(&self, uuid: T) -> String {
533        let raw = uuid.to_string();
534        raw.parse::<ThingsId>()
535            .ok()
536            .and_then(|id| self.areas_by_uuid.get(&id))
537            .map(|a| a.title.clone())
538            .unwrap_or(raw)
539    }
540
541    pub fn resolve_project_title<T: ToString>(&self, uuid: T) -> String {
542        let raw = uuid.to_string();
543        if let Ok(id) = raw.parse::<ThingsId>()
544            && let Some(task) = self.tasks_by_uuid.get(&id)
545            && !task.title.trim().is_empty()
546        {
547            return task.title.clone();
548        }
549        if raw.is_empty() {
550            return "(project)".to_string();
551        }
552        let short: String = raw.chars().take(8).collect();
553        format!("(project {short})")
554    }
555
556    pub fn short_id<T: ToString>(&self, uuid: T) -> String {
557        let raw = uuid.to_string();
558        raw.parse::<ThingsId>()
559            .ok()
560            .and_then(|id| self.short_ids.get(&id).cloned())
561            .unwrap_or(raw)
562    }
563
564    pub fn project_progress<T: ToString>(&self, project_uuid: T) -> ProjectProgress {
565        project_uuid
566            .to_string()
567            .parse::<ThingsId>()
568            .ok()
569            .and_then(|id| self.project_progress_by_uuid.get(&id).cloned())
570            .unwrap_or_default()
571    }
572
573    pub fn unique_prefix_length<T: ToString>(&self, ids: &[T]) -> usize {
574        if ids.is_empty() {
575            return 0;
576        }
577        let mut max_need = 1usize;
578        for id in ids {
579            if let Ok(parsed) = id.to_string().parse::<ThingsId>()
580                && let Some(short) = self.short_ids.get(&parsed)
581            {
582                max_need = max_need.max(short.len());
583            } else {
584                max_need = max_need.max(6);
585            }
586        }
587        max_need
588    }
589
590    fn resolve_prefix<T: Clone>(
591        &self,
592        identifier: &str,
593        items: &HashMap<ThingsId, T>,
594        sorted_ids: &[ThingsId],
595        label: &str,
596    ) -> (Option<T>, String, Vec<T>) {
597        let ident = identifier.trim();
598        if ident.is_empty() {
599            return (
600                None,
601                format!("Missing {} identifier.", label.to_lowercase()),
602                Vec::new(),
603            );
604        }
605
606        if let Ok(exact_id) = ident.parse::<ThingsId>()
607            && let Some(exact) = items.get(&exact_id)
608        {
609            return (Some(exact.clone()), String::new(), Vec::new());
610        }
611
612        let matches: Vec<&ThingsId> = prefix_matches(sorted_ids, ident);
613        if matches.len() == 1
614            && let Some(item) = items.get(matches[0])
615        {
616            return (Some(item.clone()), String::new(), Vec::new());
617        }
618
619        if matches.len() > 1 {
620            let mut out = Vec::new();
621            for m in matches.iter().take(10) {
622                if let Some(item) = items.get(*m) {
623                    out.push(item.clone());
624                }
625            }
626            let remaining = matches.len().saturating_sub(out.len());
627            let mut msg = format!("Ambiguous {} id prefix.", label.to_lowercase());
628            if remaining > 0 {
629                msg.push_str(&format!(
630                    " ({} matches, showing first {})",
631                    matches.len(),
632                    out.len()
633                ));
634            }
635            return (None, msg, out);
636        }
637
638        (None, format!("{} not found: {}", label, identifier), Vec::new())
639    }
640
641    pub fn resolve_mark_identifier(&self, identifier: &str) -> (Option<Task>, String, Vec<Task>) {
642        let markable: HashMap<ThingsId, Task> = self
643            .markable_ids
644            .iter()
645            .filter_map(|uid| self.tasks_by_uuid.get(uid).map(|t| (uid.clone(), t.clone())))
646            .collect();
647        self.resolve_prefix(identifier, &markable, &self.markable_ids_sorted, "Item")
648    }
649
650    pub fn resolve_area_identifier(&self, identifier: &str) -> (Option<Area>, String, Vec<Area>) {
651        self.resolve_prefix(identifier, &self.areas_by_uuid, &self.area_ids_sorted, "Area")
652    }
653
654    pub fn resolve_task_identifier(&self, identifier: &str) -> (Option<Task>, String, Vec<Task>) {
655        self.resolve_prefix(identifier, &self.tasks_by_uuid, &self.task_ids_sorted, "Task")
656    }
657}