Skip to main content

things3_cloud/
common.rs

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