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}