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 const ALL_CATEGORY: &str = "";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct Task {
50 pub id: String,
52 pub title: String,
53 #[serde(default, alias = "body")]
54 pub description: Vec<Block>,
55 #[serde(default, skip_serializing_if = "String::is_empty")]
56 pub due: String,
57 #[serde(default)]
58 pub created: String,
59 #[serde(default)]
60 pub done: bool,
61 #[serde(default)]
62 pub importance: u8,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub category_id: Option<String>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub label_ids: Vec<String>,
69}
70
71impl Task {
72 pub fn new(title: &str, importance: u8, category_id: Option<String>, due: &str) -> Self {
73 Self {
74 id: new_uuid(),
75 title: title.to_string(),
76 description: Vec::new(),
77 due: due.to_string(),
78 created: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
79 done: false,
80 importance: importance.min(MAX_IMPORTANCE),
81 category_id,
82 label_ids: Vec::new(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(tag = "type", rename_all = "lowercase")]
89pub enum Block {
90 Text {
91 #[serde(default)]
92 text: String,
93 },
94 Todo {
95 #[serde(default)]
96 text: String,
97 #[serde(default)]
98 done: bool,
99 },
100 Bullet {
101 #[serde(default)]
102 text: String,
103 },
104 Number {
105 #[serde(default)]
106 text: String,
107 },
108 Link {
110 #[serde(default)]
111 url: String,
112 },
113 Image {
114 #[serde(alias = "path")]
118 attachment_id: String,
119 },
120}
121
122impl Block {
123 pub fn text(text: &str) -> Self {
124 Self::Text {
125 text: text.to_string(),
126 }
127 }
128
129 pub fn todo(text: &str, done: bool) -> Self {
130 Self::Todo {
131 text: text.to_string(),
132 done,
133 }
134 }
135
136 pub fn bullet(text: &str) -> Self {
137 Self::Bullet {
138 text: text.to_string(),
139 }
140 }
141
142 pub fn number(text: &str) -> Self {
143 Self::Number {
144 text: text.to_string(),
145 }
146 }
147
148 pub fn link(url: &str) -> Self {
149 Self::Link {
150 url: url.to_string(),
151 }
152 }
153
154 pub fn image(reference: &str) -> Self {
155 Self::Image {
156 attachment_id: reference.to_string(),
157 }
158 }
159
160 pub fn is_empty(&self) -> bool {
161 match self {
162 Self::Text { text }
163 | Self::Todo { text, .. }
164 | Self::Bullet { text }
165 | Self::Number { text }
166 | Self::Link { url: text } => text.trim().is_empty(),
167 Self::Image { .. } => false,
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Category {
174 pub id: String,
175 pub name: String,
176 #[serde(default, skip_serializing_if = "String::is_empty")]
177 pub description: String,
178}
179
180impl Category {
181 pub fn new(name: &str) -> Self {
182 Self {
183 id: new_uuid(),
184 name: name.to_string(),
185 description: String::new(),
186 }
187 }
188
189 pub fn all_tasks() -> Self {
191 Self {
192 id: ALL_CATEGORY.to_string(),
193 name: "All tasks".to_string(),
194 description: String::new(),
195 }
196 }
197
198 pub fn is_all(&self) -> bool {
199 self.id == ALL_CATEGORY
200 }
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205pub enum LabelColor {
206 #[default]
207 Red,
208 Orange,
209 Yellow,
210 Lime,
211 Green,
212 Teal,
213 Cyan,
214 Blue,
215 Indigo,
216 Purple,
217 Pink,
218 Brown,
219}
220
221impl LabelColor {
222 pub const ALL: [Self; 12] = [
224 Self::Red,
225 Self::Orange,
226 Self::Yellow,
227 Self::Lime,
228 Self::Green,
229 Self::Teal,
230 Self::Cyan,
231 Self::Blue,
232 Self::Indigo,
233 Self::Purple,
234 Self::Pink,
235 Self::Brown,
236 ];
237
238 pub const SWATCHES: [Self; 12] = Self::ALL;
240
241 pub const fn as_str(self) -> &'static str {
242 match self {
243 Self::Red => "red",
244 Self::Orange => "orange",
245 Self::Yellow => "yellow",
246 Self::Lime => "lime",
247 Self::Green => "green",
248 Self::Teal => "teal",
249 Self::Cyan => "cyan",
250 Self::Blue => "blue",
251 Self::Indigo => "indigo",
252 Self::Purple => "purple",
253 Self::Pink => "pink",
254 Self::Brown => "brown",
255 }
256 }
257
258 pub fn automatic(position: usize) -> Self {
259 Self::SWATCHES[position % Self::SWATCHES.len()]
260 }
261
262 pub fn least_used(labels: &[Label]) -> Self {
263 let counts =
264 Self::SWATCHES.map(|color| labels.iter().filter(|label| label.color == color).count());
265 Self::SWATCHES
266 .iter()
267 .copied()
268 .enumerate()
269 .min_by_key(|(position, _)| (counts[*position], *position))
270 .map(|(_, color)| color)
271 .unwrap_or_default()
272 }
273}
274
275impl fmt::Display for LabelColor {
276 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
277 formatter.write_str(self.as_str())
278 }
279}
280
281impl FromStr for LabelColor {
282 type Err = String;
283
284 fn from_str(value: &str) -> Result<Self, Self::Err> {
285 Self::ALL
286 .into_iter()
287 .find(|color| color.as_str() == value)
288 .ok_or_else(|| format!("unknown label color {value:?}"))
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293pub struct Label {
294 pub id: String,
295 pub name: String,
296 #[serde(default)]
297 pub color: LabelColor,
298}
299
300impl Label {
301 pub fn new(name: &str, color: LabelColor) -> Self {
302 Self {
303 id: new_uuid(),
304 name: name.to_string(),
305 color,
306 }
307 }
308}
309
310pub fn new_uuid() -> String {
311 uuid::Uuid::new_v4().to_string()
312}
313
314pub fn importance_marks(importance: u8) -> String {
315 "⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
316}
317
318pub const fn next_importance(importance: u8) -> u8 {
319 (importance + 1) % (MAX_IMPORTANCE + 1)
320}
321
322pub fn category_name_key(value: &str) -> String {
324 caseless_key(value.trim())
325}
326
327pub fn label_name_key(value: &str) -> String {
329 caseless_key(value.trim())
330}
331
332pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
333 let mut done = 0usize;
334 let mut total = 0usize;
335 for b in &task.description {
336 if let Block::Todo { done: d, .. } = b {
337 total += 1;
338 if *d {
339 done += 1;
340 }
341 }
342 }
343 (total > 0).then_some((done, total))
344}
345
346pub fn has_prose_or_image(task: &Task) -> bool {
347 task.description
348 .iter()
349 .any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::{LabelColor, MAX_LABEL_COLOR_LEN};
355
356 #[test]
357 fn label_colors_have_stable_storage_names_and_order() {
358 let names = LabelColor::ALL
359 .iter()
360 .map(ToString::to_string)
361 .collect::<Vec<_>>();
362 assert_eq!(
363 names,
364 [
365 "red", "orange", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo",
366 "purple", "pink", "brown",
367 ]
368 );
369 assert_eq!(
370 names.iter().map(|name| name.len()).max(),
371 Some(MAX_LABEL_COLOR_LEN)
372 );
373 for color in LabelColor::ALL {
374 assert_eq!(color.to_string().parse::<LabelColor>().unwrap(), color);
375 }
376 assert!("violet".parse::<LabelColor>().is_err());
377 assert!("gray".parse::<LabelColor>().is_err());
378 assert_eq!(
379 serde_json::to_string(&LabelColor::Brown).unwrap(),
380 r#""brown""#
381 );
382 assert_eq!(
383 LabelColor::SWATCHES,
384 [
385 LabelColor::Red,
386 LabelColor::Orange,
387 LabelColor::Yellow,
388 LabelColor::Lime,
389 LabelColor::Green,
390 LabelColor::Teal,
391 LabelColor::Cyan,
392 LabelColor::Blue,
393 LabelColor::Indigo,
394 LabelColor::Purple,
395 LabelColor::Pink,
396 LabelColor::Brown,
397 ]
398 );
399 assert_eq!(
400 LabelColor::automatic(LabelColor::SWATCHES.len()),
401 LabelColor::Red
402 );
403 }
404}