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
12pub 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
18pub 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 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 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 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 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 pub checklist_open: &'static str,
70 pub checklist_done: &'static str,
71 pub checklist_canceled: &'static str,
72
73 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}