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
8pub 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
14pub 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 pub inbox: &'static str,
33 pub today: &'static str,
34 pub upcoming: &'static str,
35 pub anytime: &'static str,
36
37 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 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 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 pub checklist_open: &'static str,
65 pub checklist_done: &'static str,
66 pub checklist_canceled: &'static str,
67
68 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}