Skip to main content

things3_cloud/
common.rs

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
10/// Return today as a UTC midnight `DateTime<Utc>`.
11pub 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
16/// Return current wall-clock unix timestamp in seconds (fractional).
17pub 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 &note_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}