1use crate::store::{ChecklistItem, Tag, Task, ThingsStore};
2use crate::ids::ThingsId;
3use crate::wire::notes::{StructuredTaskNotes, TaskNotes};
4use crate::wire::task::TaskStart;
5use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone, Utc};
6use crc32fast::Hasher;
7use serde_json::Value;
8use std::collections::{HashMap, HashSet};
9
10pub fn today_utc() -> DateTime<Utc> {
12 let today = Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap();
13 Utc.from_utc_datetime(&today)
14}
15
16pub fn now_ts_f64() -> f64 {
18 Utc::now().timestamp_millis() as f64 / 1000.0
19}
20
21pub const RESET: &str = "\x1b[0m";
22pub const BOLD: &str = "\x1b[1m";
23pub const DIM: &str = "\x1b[2m";
24pub const CYAN: &str = "\x1b[36m";
25pub const YELLOW: &str = "\x1b[33m";
26pub const GREEN: &str = "\x1b[32m";
27pub const BLUE: &str = "\x1b[34m";
28pub const MAGENTA: &str = "\x1b[35m";
29pub const RED: &str = "\x1b[31m";
30
31#[derive(Debug, Clone, Copy)]
32pub struct Icons {
33 pub task_open: &'static str,
34 pub task_done: &'static str,
35 pub task_someday: &'static str,
36 pub task_canceled: &'static str,
37 pub evening: &'static str,
38 pub today: &'static str,
39 pub today_staged: &'static str,
40 pub project: &'static str,
41 pub area: &'static str,
42 pub tag: &'static str,
43 pub inbox: &'static str,
44 pub anytime: &'static str,
45 pub upcoming: &'static str,
46 pub progress_empty: &'static str,
47 pub progress_quarter: &'static str,
48 pub progress_half: &'static str,
49 pub progress_three_quarter: &'static str,
50 pub progress_full: &'static str,
51 pub deadline: &'static str,
52 pub done: &'static str,
53 pub incomplete: &'static str,
54 pub canceled: &'static str,
55 pub deleted: &'static str,
56 pub checklist_open: &'static str,
57 pub checklist_done: &'static str,
58 pub checklist_canceled: &'static str,
59 pub separator: &'static str,
60 pub divider: &'static str,
61}
62
63pub const ICONS: Icons = Icons {
64 task_open: "▢",
65 task_done: "◼",
66 task_someday: "⬚",
67 task_canceled: "☒",
68 evening: "☽",
69 today: "⭑",
70 today_staged: "●",
71 project: "●",
72 area: "◆",
73 tag: "⌗",
74 inbox: "⬓",
75 anytime: "◌",
76 upcoming: "▷",
77 progress_empty: "◯",
78 progress_quarter: "◔",
79 progress_half: "◑",
80 progress_three_quarter: "◕",
81 progress_full: "◉",
82 deadline: "⚑",
83 done: "✓",
84 incomplete: "↺",
85 canceled: "☒",
86 deleted: "×",
87 checklist_open: "○",
88 checklist_done: "●",
89 checklist_canceled: "×",
90 separator: "·",
91 divider: "─",
92};
93
94pub fn colored<T: ToString>(text: T, codes: &[&str], no_color: bool) -> String {
95 let text = text.to_string();
96 if no_color {
97 return text;
98 }
99 let mut out = String::new();
100 for code in codes {
101 out.push_str(code);
102 }
103 out.push_str(&text);
104 out.push_str(RESET);
105 out
106}
107
108pub fn fmt_date(dt: Option<DateTime<Utc>>) -> String {
109 dt.map(|d| d.format("%Y-%m-%d").to_string())
110 .unwrap_or_default()
111}
112
113pub fn fmt_date_local(dt: Option<DateTime<Utc>>) -> String {
114 let fixed_local = fixed_local_offset();
115 dt.map(|d| {
116 d.with_timezone(&fixed_local)
117 .format("%Y-%m-%d")
118 .to_string()
119 })
120 .unwrap_or_default()
121}
122
123fn fixed_local_offset() -> FixedOffset {
124 let seconds = Local::now().offset().local_minus_utc();
125 FixedOffset::east_opt(seconds).unwrap_or_else(|| FixedOffset::east_opt(0).expect("UTC offset"))
126}
127
128pub fn fmt_deadline(deadline: Option<DateTime<Utc>>, today: &DateTime<Utc>, no_color: bool) -> String {
129 let Some(deadline) = deadline else {
130 return String::new();
131 };
132 let color = if deadline < *today { RED } else { YELLOW };
133 format!(
134 " {} due by {}",
135 ICONS.deadline,
136 colored(&fmt_date(Some(deadline)), &[color], no_color)
137 )
138}
139
140fn task_box(task: &Task) -> &'static str {
141 if task.is_completed() {
142 ICONS.task_done
143 } else if task.is_canceled() {
144 ICONS.task_canceled
145 } else if task.in_someday() {
146 ICONS.task_someday
147 } else {
148 ICONS.task_open
149 }
150}
151
152pub fn id_prefix<T: ToString>(uuid: T, size: usize, no_color: bool) -> String {
153 let mut short = uuid.to_string().chars().take(size).collect::<String>();
154 while short.len() < size {
155 short.push(' ');
156 }
157 colored(&short, &[DIM], no_color)
158}
159
160pub fn fmt_task_line(
161 task: &Task,
162 store: &ThingsStore,
163 show_project: bool,
164 show_today_markers: bool,
165 show_staged_today_marker: bool,
166 id_prefix_len: Option<usize>,
167 today: &DateTime<Utc>,
168 no_color: bool,
169) -> String {
170 let mut parts: Vec<String> = Vec::new();
171
172 let box_text = colored(task_box(task), &[DIM], no_color);
173 parts.push(box_text);
174
175 if show_today_markers {
176 if task.evening {
177 parts.push(colored(ICONS.evening, &[BLUE], no_color));
178 } else if task.is_today(today) {
179 parts.push(colored(ICONS.today, &[YELLOW], no_color));
180 }
181 } else if show_staged_today_marker && task.is_staged_for_today(today) {
182 parts.push(colored(ICONS.today_staged, &[YELLOW], no_color));
183 }
184
185 let title = if task.title.is_empty() {
186 colored("(untitled)", &[DIM], no_color)
187 } else {
188 task.title.clone()
189 };
190 parts.push(title);
191
192 if !task.tags.is_empty() {
193 let tag_names: Vec<String> = task
194 .tags
195 .iter()
196 .map(|t| store.resolve_tag_title(t))
197 .collect();
198 parts.push(colored(
199 &format!(" [{}]", tag_names.join(", ")),
200 &[DIM],
201 no_color,
202 ));
203 }
204
205 if show_project
206 && let Some(effective_project) = store.effective_project_uuid(task)
207 {
208 let title = store.resolve_project_title(&effective_project);
209 parts.push(colored(
210 &format!(" {} {}", ICONS.separator, title),
211 &[DIM],
212 no_color,
213 ));
214 }
215
216 if task.deadline.is_some() {
217 parts.push(fmt_deadline(task.deadline, today, no_color));
218 }
219
220 let line = parts.join(" ");
221 if let Some(len) = id_prefix_len
222 && len > 0
223 {
224 return format!("{} {}", id_prefix(&task.uuid, len, no_color), line);
225 }
226 line
227}
228
229pub fn fmt_project_line(
230 project: &Task,
231 store: &ThingsStore,
232 show_indicators: bool,
233 show_staged_today_marker: bool,
234 id_prefix_len: Option<usize>,
235 today: &DateTime<Utc>,
236 no_color: bool,
237) -> String {
238 let title = if project.title.is_empty() {
239 colored("(untitled)", &[DIM], no_color)
240 } else {
241 project.title.clone()
242 };
243 let dl = fmt_deadline(project.deadline, today, no_color);
244
245 let marker = if project.in_someday() {
246 ICONS.anytime
247 } else {
248 let progress = store.project_progress(&project.uuid);
249 let total = progress.total;
250 let done = progress.done;
251 if total == 0 || done == 0 {
252 ICONS.progress_empty
253 } else if done == total {
254 ICONS.progress_full
255 } else {
256 let ratio = done as f32 / total as f32;
257 if ratio < (1.0 / 3.0) {
258 ICONS.progress_quarter
259 } else if ratio < (2.0 / 3.0) {
260 ICONS.progress_half
261 } else {
262 ICONS.progress_three_quarter
263 }
264 }
265 };
266
267 let mut status_marker = String::new();
268 if show_indicators {
269 if project.evening {
270 status_marker = format!(" {}", colored(ICONS.evening, &[BLUE], no_color));
271 } else if project.is_today(today) {
272 status_marker = format!(" {}", colored(ICONS.today, &[YELLOW], no_color));
273 }
274 } else if show_staged_today_marker && project.is_staged_for_today(today) {
275 status_marker = format!(" {}", colored(ICONS.today_staged, &[YELLOW], no_color));
276 }
277
278 let id_part = if let Some(len) = id_prefix_len {
279 if len > 0 {
280 format!("{} ", id_prefix(&project.uuid, len, no_color))
281 } else {
282 String::new()
283 }
284 } else {
285 String::new()
286 };
287
288 format!(
289 "{}{}{} {}{}",
290 id_part,
291 colored(marker, &[DIM], no_color),
292 status_marker,
293 title,
294 dl
295 )
296}
297
298fn note_indent(id_prefix_len: Option<usize>) -> String {
299 let width = id_prefix_len
300 .unwrap_or(0)
301 .saturating_add(if id_prefix_len.unwrap_or(0) > 0 { 1 } else { 0 });
302 " ".repeat(width)
303}
304
305fn checklist_prefix_len(items: &[ChecklistItem]) -> usize {
306 if items.is_empty() {
307 return 0;
308 }
309 for length in 1..=22 {
310 let mut set = std::collections::HashSet::new();
311 let unique = items
312 .iter()
313 .map(|item| item.uuid.to_string().chars().take(length).collect::<String>())
314 .all(|id| set.insert(id));
315 if unique {
316 return length;
317 }
318 }
319 4
320}
321
322fn checklist_icon(item: &ChecklistItem, no_color: bool) -> String {
323 if item.is_completed() {
324 colored(ICONS.checklist_done, &[DIM], no_color)
325 } else if item.is_canceled() {
326 colored(ICONS.checklist_canceled, &[DIM], no_color)
327 } else {
328 colored(ICONS.checklist_open, &[DIM], no_color)
329 }
330}
331
332pub fn fmt_task_with_note(
333 line: String,
334 task: &Task,
335 indent: &str,
336 id_prefix_len: Option<usize>,
337 detailed: bool,
338 no_color: bool,
339) -> String {
340 let mut out = vec![format!("{}{}", indent, line)];
341 if !detailed {
342 return out.join("\n");
343 }
344
345 let note_pad = format!("{}{}", indent, note_indent(id_prefix_len));
346 let has_checklist = !task.checklist_items.is_empty();
347 let pipe = colored("│", &[DIM], no_color);
348 let note_lines: Vec<String> = task
349 .notes
350 .as_ref()
351 .map(|n| n.lines().map(ToString::to_string).collect())
352 .unwrap_or_default();
353
354 if has_checklist {
355 let items = &task.checklist_items;
356 let cl_prefix_len = checklist_prefix_len(items);
357 let col = id_prefix_len.unwrap_or(0);
358 if !note_lines.is_empty() {
359 for note_line in ¬e_lines {
360 out.push(format!(
361 "{}{} {} {}",
362 indent,
363 " ".repeat(col),
364 pipe,
365 colored(note_line, &[DIM], no_color)
366 ));
367 }
368 out.push(format!("{}{} {}", indent, " ".repeat(col), pipe));
369 }
370
371 for (i, item) in items.iter().enumerate() {
372 let connector = colored(
373 if i == items.len() - 1 {
374 "└╴"
375 } else {
376 "├╴"
377 },
378 &[DIM],
379 no_color,
380 );
381 let cl_id_raw = item
382 .uuid
383 .to_string()
384 .chars()
385 .take(cl_prefix_len)
386 .collect::<String>();
387 let cl_id = colored(
388 &format!("{:>width$}", cl_id_raw, width = col),
389 &[DIM],
390 no_color,
391 );
392 out.push(format!(
393 "{}{} {}{} {}",
394 indent,
395 cl_id,
396 connector,
397 checklist_icon(item, no_color),
398 item.title
399 ));
400 }
401 } else if !note_lines.is_empty() {
402 for note_line in note_lines.iter().take(note_lines.len().saturating_sub(1)) {
403 out.push(format!(
404 "{}{} {}",
405 note_pad,
406 pipe,
407 colored(note_line, &[DIM], no_color)
408 ));
409 }
410 if let Some(last) = note_lines.last() {
411 out.push(format!(
412 "{}{} {}",
413 note_pad,
414 colored("└", &[DIM], no_color),
415 colored(last, &[DIM], no_color)
416 ));
417 }
418 }
419
420 out.join("\n")
421}
422
423#[allow(clippy::too_many_arguments)]
424pub fn fmt_project_with_note(
425 project: &Task,
426 store: &ThingsStore,
427 indent: &str,
428 id_prefix_len: Option<usize>,
429 show_indicators: bool,
430 show_staged_today_marker: bool,
431 detailed: bool,
432 today: &DateTime<Utc>,
433 no_color: bool,
434) -> String {
435 let line = fmt_project_line(
436 project,
437 store,
438 show_indicators,
439 show_staged_today_marker,
440 id_prefix_len,
441 today,
442 no_color,
443 );
444 let mut out = vec![format!("{}{}", indent, line)];
445
446 if detailed
447 && let Some(notes) = &project.notes
448 {
449 let width =
450 id_prefix_len.unwrap_or(0) + if id_prefix_len.unwrap_or(0) > 0 { 1 } else { 0 };
451 let note_pad = format!("{}{}", indent, " ".repeat(width));
452 let lines: Vec<&str> = notes.lines().collect();
453 for note in lines.iter().take(lines.len().saturating_sub(1)) {
454 out.push(format!(
455 "{}{} {}",
456 note_pad,
457 colored("│", &[DIM], no_color),
458 colored(note, &[DIM], no_color)
459 ));
460 }
461 if let Some(last) = lines.last() {
462 out.push(format!(
463 "{}{} {}",
464 note_pad,
465 colored("└", &[DIM], no_color),
466 colored(last, &[DIM], no_color)
467 ));
468 }
469 }
470
471 out.join("\n")
472}
473
474#[derive(Default)]
475struct AreaTaskGroup<'a> {
476 tasks: Vec<&'a Task>,
477 projects: Vec<(ThingsId, Vec<&'a Task>)>,
478 project_pos: HashMap<ThingsId, usize>,
479}
480
481#[allow(clippy::too_many_arguments)]
482pub fn fmt_tasks_grouped(
483 tasks: &[Task],
484 store: &ThingsStore,
485 indent: &str,
486 show_today_markers: bool,
487 detailed: bool,
488 today: &DateTime<Utc>,
489 no_color: bool,
490) -> String {
491 if tasks.is_empty() {
492 return String::new();
493 }
494
495 const MAX_GROUP_ITEMS: usize = 3;
496
497 let mut unscoped: Vec<&Task> = Vec::new();
498
499 let mut project_only: Vec<(ThingsId, Vec<&Task>)> = Vec::new();
500 let mut project_only_pos: HashMap<ThingsId, usize> = HashMap::new();
501
502 let mut by_area: Vec<(ThingsId, AreaTaskGroup<'_>)> = Vec::new();
503 let mut by_area_pos: HashMap<ThingsId, usize> = HashMap::new();
504
505 for task in tasks {
506 let project_uuid = store.effective_project_uuid(task);
507 let area_uuid = store.effective_area_uuid(task);
508
509 match (project_uuid, area_uuid) {
510 (Some(project_uuid), Some(area_uuid)) => {
511 let area_idx = if let Some(i) = by_area_pos.get(&area_uuid).copied() {
512 i
513 } else {
514 let i = by_area.len();
515 by_area.push((area_uuid.clone(), AreaTaskGroup::default()));
516 by_area_pos.insert(area_uuid.clone(), i);
517 i
518 };
519 let area_group = &mut by_area[area_idx].1;
520
521 let project_idx = if let Some(i) = area_group.project_pos.get(&project_uuid).copied() {
522 i
523 } else {
524 let i = area_group.projects.len();
525 area_group.projects.push((project_uuid.clone(), Vec::new()));
526 area_group.project_pos.insert(project_uuid.clone(), i);
527 i
528 };
529 area_group.projects[project_idx].1.push(task);
530 }
531 (Some(project_uuid), None) => {
532 let project_idx = if let Some(i) = project_only_pos.get(&project_uuid).copied() {
533 i
534 } else {
535 let i = project_only.len();
536 project_only.push((project_uuid.clone(), Vec::new()));
537 project_only_pos.insert(project_uuid.clone(), i);
538 i
539 };
540 project_only[project_idx].1.push(task);
541 }
542 (None, Some(area_uuid)) => {
543 let area_idx = if let Some(i) = by_area_pos.get(&area_uuid).copied() {
544 i
545 } else {
546 let i = by_area.len();
547 by_area.push((area_uuid.clone(), AreaTaskGroup::default()));
548 by_area_pos.insert(area_uuid.clone(), i);
549 i
550 };
551 by_area[area_idx].1.tasks.push(task);
552 }
553 (None, None) => {
554 unscoped.push(task);
555 }
556 }
557 }
558
559 let mut ids: Vec<ThingsId> = tasks.iter().map(|t| t.uuid.clone()).collect();
560 for (project_uuid, _) in &project_only {
561 ids.push(project_uuid.clone());
562 }
563 for (area_uuid, area_group) in &by_area {
564 ids.push(area_uuid.clone());
565 for (project_uuid, _) in &area_group.projects {
566 ids.push(project_uuid.clone());
567 }
568 }
569 let id_prefix_len = store.unique_prefix_length(&ids);
570
571 let mut sections: Vec<String> = Vec::new();
572
573 if !unscoped.is_empty() {
574 let mut lines: Vec<String> = Vec::new();
575 for task in unscoped {
576 let line = fmt_task_line(
577 task,
578 store,
579 false,
580 show_today_markers,
581 false,
582 Some(id_prefix_len),
583 today,
584 no_color,
585 );
586 lines.push(fmt_task_with_note(
587 line,
588 task,
589 indent,
590 Some(id_prefix_len),
591 detailed,
592 no_color,
593 ));
594 }
595 sections.push(lines.join("\n"));
596 }
597
598 let fmt_limited_tasks = |group_tasks: &[&Task], task_indent: &str| -> Vec<String> {
599 let mut lines: Vec<String> = Vec::new();
600 for task in group_tasks.iter().take(MAX_GROUP_ITEMS) {
601 let line = fmt_task_line(
602 task,
603 store,
604 false,
605 show_today_markers,
606 false,
607 Some(id_prefix_len),
608 today,
609 no_color,
610 );
611 lines.push(fmt_task_with_note(
612 line,
613 task,
614 task_indent,
615 Some(id_prefix_len),
616 detailed,
617 no_color,
618 ));
619 }
620 let hidden = group_tasks.len().saturating_sub(MAX_GROUP_ITEMS);
621 if hidden > 0 {
622 lines.push(colored(
623 &format!("{task_indent}Hiding {hidden} more"),
624 &[DIM],
625 no_color,
626 ));
627 }
628 lines
629 };
630
631 for (project_uuid, project_tasks) in &project_only {
632 let title = store.resolve_project_title(project_uuid);
633 let mut lines = vec![format!(
634 "{}{} {}",
635 indent,
636 id_prefix(project_uuid, id_prefix_len, no_color),
637 colored(&format!("{} {}", ICONS.project, title), &[BOLD], no_color)
638 )];
639 lines.extend(fmt_limited_tasks(project_tasks, &format!("{} ", indent)));
640 sections.push(lines.join("\n"));
641 }
642
643 for (area_uuid, area_group) in &by_area {
644 let area_title = store.resolve_area_title(area_uuid);
645 let mut lines = vec![format!(
646 "{}{} {}",
647 indent,
648 id_prefix(area_uuid, id_prefix_len, no_color),
649 colored(&format!("{} {}", ICONS.area, area_title), &[BOLD], no_color)
650 )];
651
652 lines.extend(fmt_limited_tasks(&area_group.tasks, &format!("{} ", indent)));
653
654 for (project_uuid, project_tasks) in &area_group.projects {
655 let project_title = store.resolve_project_title(project_uuid);
656 lines.push(format!(
657 "{} {} {}",
658 indent,
659 id_prefix(project_uuid, id_prefix_len, no_color),
660 colored(&format!("{} {}", ICONS.project, project_title), &[BOLD], no_color)
661 ));
662 lines.extend(fmt_limited_tasks(project_tasks, &format!("{} ", indent)));
663 }
664
665 sections.push(lines.join("\n"));
666 }
667
668 sections.join("\n\n")
669}
670
671pub fn parse_day(day: Option<&str>, label: &str) -> Result<Option<DateTime<Local>>, String> {
672 let Some(day) = day else {
673 return Ok(None);
674 };
675 let parsed = NaiveDate::parse_from_str(day, "%Y-%m-%d")
676 .map_err(|_| format!("Invalid {label} date: {day} (expected YYYY-MM-DD)"))?;
677 let fixed_local = fixed_local_offset();
678 let local_dt = parsed
679 .and_hms_opt(0, 0, 0)
680 .and_then(|d| fixed_local.from_local_datetime(&d).single())
681 .map(|d| d.with_timezone(&Local))
682 .ok_or_else(|| format!("Invalid {label} date: {day} (expected YYYY-MM-DD)"))?;
683 Ok(Some(local_dt))
684}
685
686pub fn day_to_timestamp(day: DateTime<Local>) -> i64 {
687 day.with_timezone(&Utc).timestamp()
688}
689
690pub fn task6_note(value: &str) -> TaskNotes {
691 let mut hasher = Hasher::new();
692 hasher.update(value.as_bytes());
693 let checksum = hasher.finalize();
694 TaskNotes::Structured(StructuredTaskNotes {
695 object_type: Some("tx".to_string()),
696 format_type: 1,
697 ch: Some(checksum),
698 v: Some(value.to_string()),
699 ps: Vec::new(),
700 unknown_fields: Default::default(),
701 })
702}
703
704pub fn task6_note_value(value: &str) -> Value {
705 serde_json::to_value(task6_note(value)).unwrap_or(Value::Null)
706}
707
708pub fn resolve_single_tag(store: &ThingsStore, identifier: &str) -> (Option<Tag>, String) {
709 let identifier = identifier.trim();
710 let all_tags = store.tags();
711
712 let exact = all_tags
713 .iter()
714 .filter(|t| t.title.eq_ignore_ascii_case(identifier))
715 .cloned()
716 .collect::<Vec<_>>();
717 if exact.len() == 1 {
718 return (exact.first().cloned(), String::new());
719 }
720 if exact.len() > 1 {
721 return (None, format!("Ambiguous tag title: {identifier}"));
722 }
723
724 let prefix = all_tags
725 .iter()
726 .filter(|t| t.uuid.starts_with(identifier))
727 .cloned()
728 .collect::<Vec<_>>();
729 if prefix.len() == 1 {
730 return (prefix.first().cloned(), String::new());
731 }
732 if prefix.len() > 1 {
733 return (None, format!("Ambiguous tag UUID prefix: {identifier}"));
734 }
735
736 (None, format!("Tag not found: {identifier}"))
737}
738
739pub fn resolve_tag_ids(store: &ThingsStore, raw_tags: &str) -> (Vec<ThingsId>, String) {
740 let tokens = raw_tags
741 .split(',')
742 .map(str::trim)
743 .filter(|t| !t.is_empty())
744 .collect::<Vec<_>>();
745 if tokens.is_empty() {
746 return (Vec::new(), String::new());
747 }
748
749 let all_tags = store.tags();
750 let mut resolved = Vec::new();
751 let mut seen = HashSet::new();
752
753 for token in tokens {
754 let exact = all_tags
755 .iter()
756 .filter(|tag| tag.title.eq_ignore_ascii_case(token))
757 .cloned()
758 .collect::<Vec<_>>();
759
760 if exact.len() == 1 {
761 let tag_uuid = exact[0].uuid.clone();
762 if seen.insert(tag_uuid.clone()) {
763 resolved.push(tag_uuid);
764 }
765 continue;
766 }
767 if exact.len() > 1 {
768 return (Vec::new(), format!("Ambiguous tag title: {token}"));
769 }
770
771 let prefix = all_tags
772 .iter()
773 .filter(|tag| tag.uuid.starts_with(token))
774 .cloned()
775 .collect::<Vec<_>>();
776
777 if prefix.len() == 1 {
778 let tag_uuid = prefix[0].uuid.clone();
779 if seen.insert(tag_uuid.clone()) {
780 resolved.push(tag_uuid);
781 }
782 continue;
783 }
784 if prefix.len() > 1 {
785 return (Vec::new(), format!("Ambiguous tag UUID prefix: {token}"));
786 }
787
788 return (Vec::new(), format!("Tag not found: {token}"));
789 }
790
791 (resolved, String::new())
792}
793
794pub fn is_today_from_props(
795 task_props: &serde_json::Map<String, Value>,
796 today_ts: i64,
797) -> bool {
798 let st = task_props.get("st").and_then(Value::as_i64).unwrap_or(0);
799 if st != i32::from(TaskStart::Anytime) as i64 {
800 return false;
801 }
802 let sr = task_props.get("sr").and_then(Value::as_i64);
803 let Some(sr) = sr else {
804 return false;
805 };
806
807 let today_ts_local = today_ts;
808 sr <= today_ts_local
809}