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