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::ThingsId;
11use crate::ids::matching::{prefix_matches, shortest_unique_prefixes};
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
145                            .insert(tag.title.clone(), tag.uuid.clone());
146                    }
147                    self.tags_by_uuid.insert(uuid.clone(), tag);
148                }
149                Some(
150                    EntityType::ChecklistItem
151                    | EntityType::ChecklistItem2
152                    | EntityType::ChecklistItem3,
153                ) => {
154                    if let StateProperties::ChecklistItem(props) = &obj.properties {
155                        checklist_items.push(self.parse_checklist_item(uuid, props));
156                    }
157                }
158                _ => {}
159            }
160        }
161
162        let mut by_task: HashMap<ThingsId, Vec<ChecklistItem>> = HashMap::new();
163        for item in checklist_items {
164            if self.tasks_by_uuid.contains_key(&item.task_uuid) {
165                by_task
166                    .entry(item.task_uuid.clone())
167                    .or_default()
168                    .push(item);
169            }
170        }
171
172        for (task_uuid, items) in by_task.iter_mut() {
173            items.sort_by_key(|i| i.index);
174            if let Some(task) = self.tasks_by_uuid.get_mut(task_uuid) {
175                task.checklist_items = items.clone();
176            }
177        }
178    }
179
180    fn parse_task(&self, uuid: &ThingsId, p: &TaskStateProps, entity: &str) -> Task {
181        Task {
182            uuid: uuid.clone(),
183            title: p.title.clone(),
184            status: p.status,
185            start: p.start_location,
186            item_type: p.item_type,
187            entity: entity.to_string(),
188            notes: p.notes.clone(),
189            project: p.parent_project_ids.first().cloned(),
190            area: p.area_ids.first().cloned(),
191            action_group: p.action_group_ids.first().cloned(),
192            tags: p.tag_ids.clone(),
193            trashed: p.trashed,
194            deadline: ts_to_dt(p.deadline),
195            start_date: ts_to_dt(p.scheduled_date),
196            stop_date: ts_to_dt(p.stop_date),
197            creation_date: ts_to_dt(p.creation_date),
198            modification_date: ts_to_dt(p.modification_date),
199            index: p.sort_index,
200            today_index: p.today_sort_index,
201            today_index_reference: p.today_index_reference,
202            leaves_tombstone: p.leaves_tombstone,
203            instance_creation_paused: p.instance_creation_paused,
204            evening: p.evening_bit != 0,
205            recurrence_rule: p.recurrence_rule.clone(),
206            recurrence_templates: p.recurrence_template_ids.clone(),
207            checklist_items: Vec::new(),
208        }
209    }
210
211    fn parse_checklist_item(&self, uuid: &ThingsId, p: &ChecklistItemStateProps) -> ChecklistItem {
212        ChecklistItem {
213            uuid: uuid.clone(),
214            title: p.title.clone(),
215            task_uuid: p.task_ids.first().cloned().unwrap_or_default(),
216            status: p.status,
217            index: p.sort_index,
218        }
219    }
220
221    fn parse_area(&self, uuid: &ThingsId, p: &AreaStateProps) -> Area {
222        Area {
223            uuid: uuid.clone(),
224            title: p.title.clone(),
225            tags: p.tag_ids.clone(),
226            index: p.sort_index,
227        }
228    }
229
230    fn parse_tag(&self, uuid: &ThingsId, p: &TagStateProps) -> Tag {
231        Tag {
232            uuid: uuid.clone(),
233            title: p.title.clone(),
234            shortcut: p.shortcut.clone(),
235            index: p.sort_index,
236            parent_uuid: p.parent_ids.first().cloned(),
237        }
238    }
239
240    pub fn tasks(
241        &self,
242        status: Option<TaskStatus>,
243        trashed: Option<bool>,
244        item_type: Option<TaskType>,
245    ) -> Vec<Task> {
246        let mut out: Vec<Task> = self
247            .tasks_by_uuid
248            .values()
249            .filter(|task| {
250                if let Some(expect_trashed) = trashed
251                    && task.trashed != expect_trashed
252                {
253                    return false;
254                }
255                if let Some(expect_status) = status
256                    && task.status != expect_status
257                {
258                    return false;
259                }
260                if let Some(expect_type) = item_type
261                    && task.item_type != expect_type
262                {
263                    return false;
264                }
265                if task.is_heading() {
266                    return false;
267                }
268                true
269            })
270            .cloned()
271            .collect();
272        out.sort_by_key(|t| t.index);
273        out
274    }
275
276    pub fn today(&self, today: &DateTime<Utc>) -> Vec<Task> {
277        let mut out: Vec<Task> = self
278            .tasks_by_uuid
279            .values()
280            .filter(|t| {
281                !t.trashed
282                    && t.status == TaskStatus::Incomplete
283                    && !t.is_heading()
284                    && !t.is_project()
285                    && !t.title.trim().is_empty()
286                    && t.entity == "Task6"
287                    && t.is_today(today)
288            })
289            .cloned()
290            .collect();
291
292        out.sort_by_key(|task| {
293            if task.today_index == 0 {
294                let sr_ts = task.start_date.map(|d| d.timestamp()).unwrap_or(0);
295                (0i32, Reverse(sr_ts), Reverse(task.index))
296            } else {
297                (1i32, Reverse(task.today_index as i64), Reverse(task.index))
298            }
299        });
300        out
301    }
302
303    pub fn inbox(&self) -> Vec<Task> {
304        let mut out: Vec<Task> = self
305            .tasks_by_uuid
306            .values()
307            .filter(|t| {
308                !t.trashed
309                    && t.status == TaskStatus::Incomplete
310                    && t.start == TaskStart::Inbox
311                    && self.effective_project_uuid(t).is_none()
312                    && self.effective_area_uuid(t).is_none()
313                    && !t.is_project()
314                    && !t.is_heading()
315                    && !t.title.trim().is_empty()
316                    && t.creation_date.is_some()
317                    && t.entity == "Task6"
318            })
319            .cloned()
320            .collect();
321        out.sort_by_key(|t| t.index);
322        out
323    }
324
325    pub fn anytime(&self, today: &DateTime<Utc>) -> Vec<Task> {
326        let project_visible = |task: &Task, store: &ThingsStore| {
327            let Some(project_uuid) = store.effective_project_uuid(task) else {
328                return true;
329            };
330            let Some(project) = store.tasks_by_uuid.get(&project_uuid) else {
331                return true;
332            };
333            if project.trashed || project.status != TaskStatus::Incomplete {
334                return false;
335            }
336            if project.start == TaskStart::Someday {
337                return false;
338            }
339            if let Some(start_date) = project.start_date
340                && start_date > *today
341            {
342                return false;
343            }
344            true
345        };
346
347        let mut out: Vec<Task> = self
348            .tasks_by_uuid
349            .values()
350            .filter(|t| {
351                !t.trashed
352                    && t.status == TaskStatus::Incomplete
353                    && t.start == TaskStart::Anytime
354                    && !t.is_project()
355                    && !t.is_heading()
356                    && !t.title.trim().is_empty()
357                    && t.entity == "Task6"
358                    && (t.start_date.is_none() || t.start_date <= Some(*today))
359                    && project_visible(t, self)
360            })
361            .cloned()
362            .collect();
363        out.sort_by_key(|t| t.index);
364        out
365    }
366
367    pub fn someday(&self) -> Vec<Task> {
368        let mut out: Vec<Task> = self
369            .tasks_by_uuid
370            .values()
371            .filter(|t| {
372                !t.trashed
373                    && t.status == TaskStatus::Incomplete
374                    && t.start == TaskStart::Someday
375                    && !t.is_heading()
376                    && !t.title.trim().is_empty()
377                    && t.entity == "Task6"
378                    && !t.is_recurrence_template()
379                    && t.start_date.is_none()
380                    && (t.is_project() || self.effective_project_uuid(t).is_none())
381            })
382            .cloned()
383            .collect();
384        out.sort_by_key(|t| t.index);
385        out
386    }
387
388    pub fn logbook(
389        &self,
390        from_date: Option<DateTime<Local>>,
391        to_date: Option<DateTime<Local>>,
392    ) -> Vec<Task> {
393        let mut out: Vec<Task> = self
394            .tasks_by_uuid
395            .values()
396            .filter(|task| {
397                if task.trashed
398                    || !(task.status == TaskStatus::Completed
399                        || task.status == TaskStatus::Canceled)
400                {
401                    return false;
402                }
403                if task.is_heading() || task.entity != "Task6" {
404                    return false;
405                }
406                let Some(stop_date) = task.stop_date else {
407                    return false;
408                };
409
410                let stop_day = stop_date
411                    .with_timezone(&fixed_local_offset())
412                    .date_naive()
413                    .and_hms_opt(0, 0, 0)
414                    .and_then(|d| fixed_local_offset().from_local_datetime(&d).single())
415                    .map(|d| d.with_timezone(&Local));
416
417                if let Some(from_day) = from_date
418                    && let Some(sd) = stop_day
419                    && sd < from_day
420                {
421                    return false;
422                }
423                if let Some(to_day) = to_date
424                    && let Some(sd) = stop_day
425                    && sd > to_day
426                {
427                    return false;
428                }
429
430                true
431            })
432            .cloned()
433            .collect();
434
435        out.sort_by_key(|t| {
436            let stop_key = t
437                .stop_date
438                .map(|d| (d.timestamp(), d.timestamp_subsec_nanos()))
439                .unwrap_or((0, 0));
440            (Reverse(stop_key), Reverse(t.index), t.uuid.clone())
441        });
442        out
443    }
444
445    pub fn effective_project_uuid(&self, task: &Task) -> Option<ThingsId> {
446        if let Some(project) = &task.project {
447            return Some(project.clone());
448        }
449        if let Some(action_group) = &task.action_group
450            && let Some(heading) = self.tasks_by_uuid.get(action_group)
451            && let Some(project) = &heading.project
452        {
453            return Some(project.clone());
454        }
455        None
456    }
457
458    pub fn effective_area_uuid(&self, task: &Task) -> Option<ThingsId> {
459        if let Some(area) = &task.area {
460            return Some(area.clone());
461        }
462
463        if let Some(project_uuid) = self.effective_project_uuid(task)
464            && let Some(project) = self.tasks_by_uuid.get(&project_uuid)
465            && let Some(area) = &project.area
466        {
467            return Some(area.clone());
468        }
469
470        if let Some(action_group) = &task.action_group
471            && let Some(heading) = self.tasks_by_uuid.get(action_group)
472            && let Some(area) = &heading.area
473        {
474            return Some(area.clone());
475        }
476
477        None
478    }
479
480    pub fn projects(&self, status: Option<TaskStatus>) -> Vec<Task> {
481        let mut out: Vec<Task> = self
482            .tasks_by_uuid
483            .values()
484            .filter(|t| {
485                !t.trashed
486                    && t.is_project()
487                    && t.entity == "Task6"
488                    && status.map(|s| t.status == s).unwrap_or(true)
489            })
490            .cloned()
491            .collect();
492        out.sort_by_key(|t| t.index);
493        out
494    }
495
496    pub fn areas(&self) -> Vec<Area> {
497        let mut out: Vec<Area> = self.areas_by_uuid.values().cloned().collect();
498        out.sort_by_key(|a| a.index);
499        out
500    }
501
502    pub fn tags(&self) -> Vec<Tag> {
503        let mut out: Vec<Tag> = self
504            .tags_by_uuid
505            .values()
506            .filter(|t| !t.title.trim().is_empty())
507            .cloned()
508            .collect();
509        out.sort_by_key(|t| t.index);
510        out
511    }
512
513    pub fn get_task(&self, uuid: &str) -> Option<Task> {
514        uuid.parse::<ThingsId>()
515            .ok()
516            .and_then(|id| self.tasks_by_uuid.get(&id).cloned())
517    }
518
519    pub fn get_area(&self, uuid: &str) -> Option<Area> {
520        uuid.parse::<ThingsId>()
521            .ok()
522            .and_then(|id| self.areas_by_uuid.get(&id).cloned())
523    }
524
525    pub fn get_tag(&self, uuid: &str) -> Option<Tag> {
526        uuid.parse::<ThingsId>()
527            .ok()
528            .and_then(|id| self.tags_by_uuid.get(&id).cloned())
529    }
530
531    pub fn resolve_tag_title<T: ToString>(&self, uuid: T) -> String {
532        let raw = uuid.to_string();
533        raw.parse::<ThingsId>()
534            .ok()
535            .and_then(|id| self.tags_by_uuid.get(&id))
536            .filter(|t| !t.title.trim().is_empty())
537            .map(|t| t.title.clone())
538            .unwrap_or(raw)
539    }
540
541    pub fn resolve_area_title<T: ToString>(&self, uuid: T) -> String {
542        let raw = uuid.to_string();
543        raw.parse::<ThingsId>()
544            .ok()
545            .and_then(|id| self.areas_by_uuid.get(&id))
546            .map(|a| a.title.clone())
547            .unwrap_or(raw)
548    }
549
550    pub fn resolve_project_title<T: ToString>(&self, uuid: T) -> String {
551        let raw = uuid.to_string();
552        if let Ok(id) = raw.parse::<ThingsId>()
553            && let Some(task) = self.tasks_by_uuid.get(&id)
554            && !task.title.trim().is_empty()
555        {
556            return task.title.clone();
557        }
558        if raw.is_empty() {
559            return "(project)".to_string();
560        }
561        let short: String = raw.chars().take(8).collect();
562        format!("(project {short})")
563    }
564
565    pub fn short_id<T: ToString>(&self, uuid: T) -> String {
566        let raw = uuid.to_string();
567        raw.parse::<ThingsId>()
568            .ok()
569            .and_then(|id| self.short_ids.get(&id).cloned())
570            .unwrap_or(raw)
571    }
572
573    pub fn project_progress<T: ToString>(&self, project_uuid: T) -> ProjectProgress {
574        project_uuid
575            .to_string()
576            .parse::<ThingsId>()
577            .ok()
578            .and_then(|id| self.project_progress_by_uuid.get(&id).cloned())
579            .unwrap_or_default()
580    }
581
582    pub fn unique_prefix_length<T: ToString>(&self, ids: &[T]) -> usize {
583        if ids.is_empty() {
584            return 0;
585        }
586        let mut max_need = 1usize;
587        for id in ids {
588            if let Ok(parsed) = id.to_string().parse::<ThingsId>()
589                && let Some(short) = self.short_ids.get(&parsed)
590            {
591                max_need = max_need.max(short.len());
592            } else {
593                max_need = max_need.max(6);
594            }
595        }
596        max_need
597    }
598
599    fn resolve_prefix<T: Clone>(
600        &self,
601        identifier: &str,
602        items: &HashMap<ThingsId, T>,
603        sorted_ids: &[ThingsId],
604        label: &str,
605    ) -> (Option<T>, String, Vec<T>) {
606        let ident = identifier.trim();
607        if ident.is_empty() {
608            return (
609                None,
610                format!("Missing {} identifier.", label.to_lowercase()),
611                Vec::new(),
612            );
613        }
614
615        if let Ok(exact_id) = ident.parse::<ThingsId>()
616            && let Some(exact) = items.get(&exact_id)
617        {
618            return (Some(exact.clone()), String::new(), Vec::new());
619        }
620
621        let matches: Vec<&ThingsId> = prefix_matches(sorted_ids, ident);
622        if matches.len() == 1
623            && let Some(item) = items.get(matches[0])
624        {
625            return (Some(item.clone()), String::new(), Vec::new());
626        }
627
628        if matches.len() > 1 {
629            let mut out = Vec::new();
630            for m in matches.iter().take(10) {
631                if let Some(item) = items.get(*m) {
632                    out.push(item.clone());
633                }
634            }
635            let remaining = matches.len().saturating_sub(out.len());
636            let mut msg = format!("Ambiguous {} id prefix.", label.to_lowercase());
637            if remaining > 0 {
638                msg.push_str(&format!(
639                    " ({} matches, showing first {})",
640                    matches.len(),
641                    out.len()
642                ));
643            }
644            return (None, msg, out);
645        }
646
647        (
648            None,
649            format!("{} not found: {}", label, identifier),
650            Vec::new(),
651        )
652    }
653
654    pub fn resolve_mark_identifier(&self, identifier: &str) -> (Option<Task>, String, Vec<Task>) {
655        let markable: HashMap<ThingsId, Task> = self
656            .markable_ids
657            .iter()
658            .filter_map(|uid| {
659                self.tasks_by_uuid
660                    .get(uid)
661                    .map(|t| (uid.clone(), t.clone()))
662            })
663            .collect();
664        self.resolve_prefix(identifier, &markable, &self.markable_ids_sorted, "Item")
665    }
666
667    pub fn resolve_area_identifier(&self, identifier: &str) -> (Option<Area>, String, Vec<Area>) {
668        self.resolve_prefix(
669            identifier,
670            &self.areas_by_uuid,
671            &self.area_ids_sorted,
672            "Area",
673        )
674    }
675
676    pub fn resolve_task_identifier(&self, identifier: &str) -> (Option<Task>, String, Vec<Task>) {
677        self.resolve_prefix(
678            identifier,
679            &self.tasks_by_uuid,
680            &self.task_ids_sorted,
681            "Task",
682        )
683    }
684}