1use caseless::Caseless;
8use chrono::Local;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::str::FromStr;
12use unicode_normalization::UnicodeNormalization;
13
14pub const SCHEMA_VERSION: u32 = 3;
16
17pub const MAX_TASK_COUNT: usize = 1024;
18pub const MAX_IMPORTANCE: u8 = 3;
19pub const MAX_TITLE_LEN: usize = 256;
20pub const MAX_NOTES_LINE_LEN: usize = 512;
21pub const MAX_DESCRIPTION_LINES: usize = 256;
22pub const MAX_CATEGORY_NAME_LEN: usize = 64;
23pub const MAX_CATEGORY_DESC_LINE_LEN: usize = 256;
24pub const MAX_CATEGORY_DESC_LINES: usize = 64;
25pub const MAX_CATEGORY_COUNT: usize = 128;
26pub const MAX_LABEL_COUNT: usize = 128;
27pub const MAX_LABELS_PER_TASK: usize = 32;
28pub const MAX_LABEL_NAME_LEN: usize = 64;
29pub const MAX_LABEL_COLOR_LEN: usize = 6;
30
31pub const fn text_byte_limit(max_graphemes: usize) -> usize {
35 let scaled = max_graphemes.saturating_mul(16);
36 if scaled < 256 { 256 } else { scaled }
37}
38
39pub fn caseless_key(value: &str) -> String {
42 value.nfkc().default_case_fold().nfkc().collect()
43}
44
45pub fn caseless_contains(haystack: &str, folded_needle: &str) -> bool {
47 if folded_needle.is_empty() {
48 return true;
49 }
50 if haystack.is_ascii() && folded_needle.is_ascii() {
51 return haystack
52 .as_bytes()
53 .windows(folded_needle.len())
54 .any(|window| window.eq_ignore_ascii_case(folded_needle.as_bytes()));
55 }
56 caseless_key(haystack).contains(folded_needle)
57}
58
59pub const ALL_CATEGORY: &str = "";
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Task {
64 pub id: String,
66 pub title: String,
67 #[serde(default, alias = "body")]
68 pub description: Vec<Block>,
69 #[serde(default, skip_serializing_if = "String::is_empty")]
70 pub due: String,
71 #[serde(default)]
72 pub created: String,
73 #[serde(default)]
74 pub done: bool,
75 #[serde(default)]
76 pub importance: u8,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub category_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub label_ids: Vec<String>,
83}
84
85impl Task {
86 pub fn new(title: &str, importance: u8, category_id: Option<String>, due: &str) -> Self {
87 Self {
88 id: new_uuid(),
89 title: title.to_string(),
90 description: Vec::new(),
91 due: due.to_string(),
92 created: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
93 done: false,
94 importance: importance.min(MAX_IMPORTANCE),
95 category_id,
96 label_ids: Vec::new(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "type", rename_all = "lowercase")]
103pub enum Block {
104 Text {
105 #[serde(default)]
106 text: String,
107 },
108 Todo {
109 #[serde(default)]
110 text: String,
111 #[serde(default)]
112 done: bool,
113 },
114 Bullet {
115 #[serde(default)]
116 text: String,
117 },
118 Number {
119 #[serde(default)]
120 text: String,
121 },
122 Link {
124 #[serde(default)]
125 url: String,
126 },
127 Image {
128 #[serde(alias = "path")]
132 attachment_id: String,
133 },
134}
135
136impl Block {
137 pub fn text(text: &str) -> Self {
138 Self::Text {
139 text: text.to_string(),
140 }
141 }
142
143 pub fn todo(text: &str, done: bool) -> Self {
144 Self::Todo {
145 text: text.to_string(),
146 done,
147 }
148 }
149
150 pub fn bullet(text: &str) -> Self {
151 Self::Bullet {
152 text: text.to_string(),
153 }
154 }
155
156 pub fn number(text: &str) -> Self {
157 Self::Number {
158 text: text.to_string(),
159 }
160 }
161
162 pub fn link(url: &str) -> Self {
163 Self::Link {
164 url: url.to_string(),
165 }
166 }
167
168 pub fn image(reference: &str) -> Self {
169 Self::Image {
170 attachment_id: reference.to_string(),
171 }
172 }
173
174 pub fn is_empty(&self) -> bool {
175 match self {
176 Self::Text { text }
177 | Self::Todo { text, .. }
178 | Self::Bullet { text }
179 | Self::Number { text }
180 | Self::Link { url: text } => text.trim().is_empty(),
181 Self::Image { .. } => false,
182 }
183 }
184}
185
186pub fn task_text_contains(task: &Task, folded_query: &str) -> bool {
189 caseless_contains(&task.title, folded_query)
190 || task.description.iter().any(|block| match block {
191 Block::Text { text }
192 | Block::Todo { text, .. }
193 | Block::Bullet { text }
194 | Block::Number { text }
195 | Block::Link { url: text } => caseless_contains(text, folded_query),
196 Block::Image { .. } => false,
197 })
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Category {
202 pub id: String,
203 pub name: String,
204 #[serde(default, skip_serializing_if = "String::is_empty")]
205 pub description: String,
206}
207
208impl Category {
209 pub fn new(name: &str) -> Self {
210 Self {
211 id: new_uuid(),
212 name: name.to_string(),
213 description: String::new(),
214 }
215 }
216
217 pub fn all_tasks() -> Self {
219 Self {
220 id: ALL_CATEGORY.to_string(),
221 name: "All tasks".to_string(),
222 description: String::new(),
223 }
224 }
225
226 pub fn is_all(&self) -> bool {
227 self.id == ALL_CATEGORY
228 }
229}
230
231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum LabelColor {
234 #[default]
235 Red,
236 Orange,
237 Yellow,
238 Lime,
239 Green,
240 Teal,
241 Cyan,
242 Blue,
243 Indigo,
244 Purple,
245 Pink,
246 Brown,
247}
248
249impl LabelColor {
250 pub const ALL: [Self; 12] = [
252 Self::Red,
253 Self::Orange,
254 Self::Yellow,
255 Self::Lime,
256 Self::Green,
257 Self::Teal,
258 Self::Cyan,
259 Self::Blue,
260 Self::Indigo,
261 Self::Purple,
262 Self::Pink,
263 Self::Brown,
264 ];
265
266 pub const SWATCHES: [Self; 12] = Self::ALL;
268
269 pub const fn as_str(self) -> &'static str {
270 match self {
271 Self::Red => "red",
272 Self::Orange => "orange",
273 Self::Yellow => "yellow",
274 Self::Lime => "lime",
275 Self::Green => "green",
276 Self::Teal => "teal",
277 Self::Cyan => "cyan",
278 Self::Blue => "blue",
279 Self::Indigo => "indigo",
280 Self::Purple => "purple",
281 Self::Pink => "pink",
282 Self::Brown => "brown",
283 }
284 }
285
286 pub fn automatic(position: usize) -> Self {
287 Self::SWATCHES[position % Self::SWATCHES.len()]
288 }
289
290 pub fn least_used(labels: &[Label]) -> Self {
291 let counts =
292 Self::SWATCHES.map(|color| labels.iter().filter(|label| label.color == color).count());
293 Self::SWATCHES
294 .iter()
295 .copied()
296 .enumerate()
297 .min_by_key(|(position, _)| (counts[*position], *position))
298 .map(|(_, color)| color)
299 .unwrap_or_default()
300 }
301}
302
303impl fmt::Display for LabelColor {
304 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305 formatter.write_str(self.as_str())
306 }
307}
308
309impl FromStr for LabelColor {
310 type Err = String;
311
312 fn from_str(value: &str) -> Result<Self, Self::Err> {
313 Self::ALL
314 .into_iter()
315 .find(|color| color.as_str() == value)
316 .ok_or_else(|| format!("unknown label color {value:?}"))
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Label {
322 pub id: String,
323 pub name: String,
324 #[serde(default)]
325 pub color: LabelColor,
326}
327
328impl Label {
329 pub fn new(name: &str, color: LabelColor) -> Self {
330 Self {
331 id: new_uuid(),
332 name: name.to_string(),
333 color,
334 }
335 }
336}
337
338pub fn labels_for_task<'a>(task: &Task, labels: &'a [Label]) -> impl Iterator<Item = &'a Label> {
340 labels
341 .iter()
342 .filter(|label| task.label_ids.contains(&label.id))
343}
344
345pub fn new_uuid() -> String {
346 uuid::Uuid::new_v4().to_string()
347}
348
349pub fn importance_marks(importance: u8) -> String {
350 "⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
351}
352
353pub const fn next_importance(importance: u8) -> u8 {
354 (importance + 1) % (MAX_IMPORTANCE + 1)
355}
356
357pub fn category_name_key(value: &str) -> String {
359 caseless_key(value.trim())
360}
361
362pub fn label_name_key(value: &str) -> String {
364 caseless_key(value.trim())
365}
366
367pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
368 let mut done = 0usize;
369 let mut total = 0usize;
370 for b in &task.description {
371 if let Block::Todo { done: d, .. } = b {
372 total += 1;
373 if *d {
374 done += 1;
375 }
376 }
377 }
378 (total > 0).then_some((done, total))
379}
380
381pub fn has_prose_or_image(task: &Task) -> bool {
382 task.description
383 .iter()
384 .any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
385}
386
387#[cfg(test)]
388mod tests {
389 use super::{LabelColor, MAX_LABEL_COLOR_LEN};
390
391 #[test]
392 fn label_colors_have_stable_storage_names_and_order() {
393 let names = LabelColor::ALL
394 .iter()
395 .map(ToString::to_string)
396 .collect::<Vec<_>>();
397 assert_eq!(
398 names,
399 [
400 "red", "orange", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo",
401 "purple", "pink", "brown",
402 ]
403 );
404 assert_eq!(
405 names.iter().map(|name| name.len()).max(),
406 Some(MAX_LABEL_COLOR_LEN)
407 );
408 for color in LabelColor::ALL {
409 assert_eq!(color.to_string().parse::<LabelColor>().unwrap(), color);
410 }
411 assert!("violet".parse::<LabelColor>().is_err());
412 assert!("gray".parse::<LabelColor>().is_err());
413 assert_eq!(
414 serde_json::to_string(&LabelColor::Brown).unwrap(),
415 r#""brown""#
416 );
417 assert_eq!(
418 LabelColor::SWATCHES,
419 [
420 LabelColor::Red,
421 LabelColor::Orange,
422 LabelColor::Yellow,
423 LabelColor::Lime,
424 LabelColor::Green,
425 LabelColor::Teal,
426 LabelColor::Cyan,
427 LabelColor::Blue,
428 LabelColor::Indigo,
429 LabelColor::Purple,
430 LabelColor::Pink,
431 LabelColor::Brown,
432 ]
433 );
434 assert_eq!(
435 LabelColor::automatic(LabelColor::SWATCHES.len()),
436 LabelColor::Red
437 );
438 }
439}