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}