Skip to main content

things3_cloud/store/
mod.rs

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