Skip to main content

things3_cloud/
common.rs

1use std::collections::HashSet;
2
3use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone, Utc};
4use crc32fast::Hasher;
5
6use crate::{
7    ids::ThingsId,
8    store::{Tag, ThingsStore},
9    wire::notes::{StructuredTaskNotes, TaskNotes},
10};
11
12/// Return today as a UTC midnight `DateTime<Utc>`.
13pub fn today_utc() -> DateTime<Utc> {
14    let today = Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap();
15    Utc.from_utc_datetime(&today)
16}
17
18/// Return current wall-clock unix timestamp in seconds (fractional).
19pub fn now_ts_f64() -> f64 {
20    Utc::now().timestamp_millis() as f64 / 1000.0
21}
22
23pub const RESET: &str = "\x1b[0m";
24pub const BOLD: &str = "\x1b[1m";
25pub const DIM: &str = "\x1b[2m";
26pub const CYAN: &str = "\x1b[36m";
27pub const YELLOW: &str = "\x1b[33m";
28pub const GREEN: &str = "\x1b[32m";
29pub const BLUE: &str = "\x1b[34m";
30pub const MAGENTA: &str = "\x1b[35m";
31pub const RED: &str = "\x1b[31m";
32
33#[derive(Debug, Clone, Copy)]
34pub struct Icons {
35    // Sidebar/view icons
36    pub inbox: &'static str,
37    pub today: &'static str,
38    pub upcoming: &'static str,
39    pub anytime: &'static str,
40    pub find: &'static str,
41
42    // Task and grouping icons
43    pub task_open: &'static str,
44    pub task_done: &'static str,
45    pub task_someday: &'static str,
46    pub task_canceled: &'static str,
47    pub today_staged: &'static str,
48    pub project: &'static str,
49    pub project_someday: &'static str,
50    pub area: &'static str,
51    pub tag: &'static str,
52    pub evening: &'static str,
53
54    // Project progress icons
55    pub progress_empty: &'static str,
56    pub progress_quarter: &'static str,
57    pub progress_half: &'static str,
58    pub progress_three_quarter: &'static str,
59    pub progress_full: &'static str,
60
61    // Status/event icons
62    pub deadline: &'static str,
63    pub done: &'static str,
64    pub incomplete: &'static str,
65    pub canceled: &'static str,
66    pub deleted: &'static str,
67
68    // Checklist icons
69    pub checklist_open: &'static str,
70    pub checklist_done: &'static str,
71    pub checklist_canceled: &'static str,
72
73    // Misc UI glyphs
74    pub separator: &'static str,
75    pub divider: &'static str,
76}
77
78pub const ICONS: Icons = Icons {
79    inbox: "⬓",
80    today: "⭑",
81    upcoming: "▷",
82    anytime: "≋",
83    find: "⌕",
84
85    task_open: "▢",
86    task_done: "◼",
87    task_someday: "⬚",
88    task_canceled: "☒",
89    today_staged: "●",
90    project: "●",
91    project_someday: "◌",
92    area: "◆",
93    tag: "⌗",
94    evening: "☽",
95
96    progress_empty: "◯",
97    progress_quarter: "◔",
98    progress_half: "◑",
99    progress_three_quarter: "◕",
100    progress_full: "◉",
101
102    deadline: "⚑",
103    done: "✓",
104    incomplete: "↺",
105    canceled: "☒",
106    deleted: "×",
107
108    checklist_open: "○",
109    checklist_done: "●",
110    checklist_canceled: "×",
111
112    separator: "·",
113    divider: "─",
114};
115
116pub fn colored<T: ToString>(text: T, codes: &[&str], no_color: bool) -> String {
117    let text = text.to_string();
118    if no_color {
119        return text;
120    }
121    let mut out = String::new();
122    for code in codes {
123        out.push_str(code);
124    }
125    out.push_str(&text);
126    out.push_str(RESET);
127    out
128}
129
130pub fn fmt_date(dt: Option<DateTime<Utc>>) -> String {
131    dt.map(|d| d.format("%Y-%m-%d").to_string())
132        .unwrap_or_default()
133}
134
135pub fn fmt_date_local(dt: Option<DateTime<Utc>>) -> String {
136    let fixed_local = fixed_local_offset();
137    dt.map(|d| d.with_timezone(&fixed_local).format("%Y-%m-%d").to_string())
138        .unwrap_or_default()
139}
140
141fn fixed_local_offset() -> FixedOffset {
142    let seconds = Local::now().offset().local_minus_utc();
143    FixedOffset::east_opt(seconds).unwrap_or_else(|| FixedOffset::east_opt(0).expect("UTC offset"))
144}
145
146pub fn parse_day(day: Option<&str>, label: &str) -> Result<Option<DateTime<Local>>, String> {
147    let Some(day) = day else {
148        return Ok(None);
149    };
150    let parsed = NaiveDate::parse_from_str(day, "%Y-%m-%d")
151        .map_err(|_| format!("Invalid {label} date: {day} (expected YYYY-MM-DD)"))?;
152    let fixed_local = fixed_local_offset();
153    let local_dt = parsed
154        .and_hms_opt(0, 0, 0)
155        .and_then(|d| fixed_local.from_local_datetime(&d).single())
156        .map(|d| d.with_timezone(&Local))
157        .ok_or_else(|| format!("Invalid {label} date: {day} (expected YYYY-MM-DD)"))?;
158    Ok(Some(local_dt))
159}
160
161pub fn day_to_timestamp(day: DateTime<Local>) -> i64 {
162    day.with_timezone(&Utc).timestamp()
163}
164
165pub fn task6_note(value: &str) -> TaskNotes {
166    let mut hasher = Hasher::new();
167    hasher.update(value.as_bytes());
168    let checksum = hasher.finalize();
169    TaskNotes::Structured(StructuredTaskNotes {
170        object_type: Some("tx".to_string()),
171        format_type: 1,
172        ch: Some(checksum),
173        v: Some(value.to_string()),
174        ps: Vec::new(),
175        unknown_fields: Default::default(),
176    })
177}
178
179pub fn resolve_single_tag(store: &ThingsStore, identifier: &str) -> (Option<Tag>, String) {
180    let identifier = identifier.trim();
181    if identifier.is_empty() {
182        return (None, format!("Tag not found: {identifier}"));
183    }
184
185    let (resolved, err) = resolve_tag_ids(store, identifier);
186    if !err.is_empty() {
187        return (None, err);
188    }
189    if resolved.len() != 1 {
190        return (None, format!("Tag not found: {identifier}"));
191    }
192
193    let all_tags = store.tags();
194    let tag = all_tags.into_iter().find(|t| t.uuid == resolved[0]);
195    match tag {
196        Some(tag) => (Some(tag), String::new()),
197        None => (None, format!("Tag not found: {identifier}")),
198    }
199}
200
201pub fn resolve_tag_ids(store: &ThingsStore, raw_tags: &str) -> (Vec<ThingsId>, String) {
202    let tokens = raw_tags
203        .split(',')
204        .map(str::trim)
205        .filter(|t| !t.is_empty())
206        .collect::<Vec<_>>();
207    if tokens.is_empty() {
208        return (Vec::new(), String::new());
209    }
210
211    let all_tags = store.tags();
212    let mut resolved = Vec::new();
213    let mut seen = HashSet::new();
214
215    for token in tokens {
216        let tag_uuid = match resolve_single_tag_id(&all_tags, token) {
217            Ok(tag_uuid) => tag_uuid,
218            Err(err) => return (Vec::new(), err),
219        };
220        if seen.insert(tag_uuid.clone()) {
221            resolved.push(tag_uuid);
222        }
223    }
224
225    (resolved, String::new())
226}
227
228fn resolve_single_tag_id(tags: &[Tag], token: &str) -> Result<ThingsId, String> {
229    let exact = tags
230        .iter()
231        .filter(|tag| tag.title.eq_ignore_ascii_case(token))
232        .map(|tag| tag.uuid.clone())
233        .collect::<Vec<_>>();
234    if exact.len() == 1 {
235        return Ok(exact[0].clone());
236    }
237    if exact.len() > 1 {
238        return Err(format!("Ambiguous tag title: {token}"));
239    }
240
241    let prefix = tags
242        .iter()
243        .filter(|tag| tag.uuid.starts_with(token))
244        .map(|tag| tag.uuid.clone())
245        .collect::<Vec<_>>();
246    if prefix.len() == 1 {
247        return Ok(prefix[0].clone());
248    }
249    if prefix.len() > 1 {
250        return Err(format!("Ambiguous tag UUID prefix: {token}"));
251    }
252
253    Err(format!("Tag not found: {token}"))
254}