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}