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 pub find: &'static str,
37
38 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 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 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 pub checklist_open: &'static str,
66 pub checklist_done: &'static str,
67 pub checklist_canceled: &'static str,
68
69 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}