1use caseless::Caseless;
8use chrono::Local;
9use serde::{Deserialize, Serialize};
10use unicode_normalization::UnicodeNormalization;
11
12pub const SCHEMA_VERSION: u32 = 3;
14
15pub const MAX_TASK_COUNT: usize = 1024;
16pub const MAX_IMPORTANCE: u8 = 3;
17pub const MAX_TITLE_LEN: usize = 256;
18pub const MAX_NOTES_LINE_LEN: usize = 512;
19pub const MAX_BODY_LINES: usize = 256;
20pub const MAX_CATEGORY_NAME_LEN: usize = 64;
21pub const MAX_CATEGORY_DESC_LINE_LEN: usize = 256;
22pub const MAX_CATEGORY_DESC_LINES: usize = 64;
23pub const MAX_CATEGORY_COUNT: usize = 128;
24
25pub const fn text_byte_limit(max_graphemes: usize) -> usize {
29 let scaled = max_graphemes.saturating_mul(16);
30 if scaled < 256 { 256 } else { scaled }
31}
32
33pub fn caseless_key(value: &str) -> String {
36 value.nfkc().default_case_fold().nfkc().collect()
37}
38
39pub const ALL_CATEGORY: &str = "";
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Task {
44 pub id: String,
46 pub title: String,
47 #[serde(default)]
48 pub body: Vec<Block>,
49 #[serde(default, skip_serializing_if = "String::is_empty")]
50 pub due: String,
51 #[serde(default)]
52 pub created: String,
53 #[serde(default)]
54 pub done: bool,
55 #[serde(default)]
56 pub importance: u8,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub category_id: Option<String>,
60}
61
62impl Task {
63 pub fn new(title: &str, importance: u8, category_id: Option<String>, due: &str) -> Self {
64 Self {
65 id: new_uuid(),
66 title: title.to_string(),
67 body: Vec::new(),
68 due: due.to_string(),
69 created: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
70 done: false,
71 importance: importance.min(MAX_IMPORTANCE),
72 category_id,
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "type", rename_all = "lowercase")]
79pub enum Block {
80 Text {
81 #[serde(default)]
82 text: String,
83 },
84 Todo {
85 #[serde(default)]
86 text: String,
87 #[serde(default)]
88 done: bool,
89 },
90 Bullet {
91 #[serde(default)]
92 text: String,
93 },
94 Number {
95 #[serde(default)]
96 text: String,
97 },
98 Link {
100 #[serde(default)]
101 url: String,
102 },
103 Image {
104 #[serde(alias = "path")]
108 attachment_id: String,
109 },
110}
111
112impl Block {
113 pub fn text(text: &str) -> Self {
114 Self::Text {
115 text: text.to_string(),
116 }
117 }
118
119 pub fn todo(text: &str, done: bool) -> Self {
120 Self::Todo {
121 text: text.to_string(),
122 done,
123 }
124 }
125
126 pub fn bullet(text: &str) -> Self {
127 Self::Bullet {
128 text: text.to_string(),
129 }
130 }
131
132 pub fn number(text: &str) -> Self {
133 Self::Number {
134 text: text.to_string(),
135 }
136 }
137
138 pub fn link(url: &str) -> Self {
139 Self::Link {
140 url: url.to_string(),
141 }
142 }
143
144 pub fn image(reference: &str) -> Self {
145 Self::Image {
146 attachment_id: reference.to_string(),
147 }
148 }
149
150 pub fn is_empty(&self) -> bool {
151 match self {
152 Self::Text { text }
153 | Self::Todo { text, .. }
154 | Self::Bullet { text }
155 | Self::Number { text }
156 | Self::Link { url: text } => text.trim().is_empty(),
157 Self::Image { .. } => false,
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Category {
164 pub id: String,
165 pub name: String,
166 #[serde(default, skip_serializing_if = "String::is_empty")]
167 pub description: String,
168}
169
170impl Category {
171 pub fn new(name: &str) -> Self {
172 Self {
173 id: new_uuid(),
174 name: name.to_string(),
175 description: String::new(),
176 }
177 }
178
179 pub fn all_tasks() -> Self {
181 Self {
182 id: ALL_CATEGORY.to_string(),
183 name: "All tasks".to_string(),
184 description: String::new(),
185 }
186 }
187
188 pub fn is_all(&self) -> bool {
189 self.id == ALL_CATEGORY
190 }
191}
192
193pub fn new_uuid() -> String {
194 uuid::Uuid::new_v4().to_string()
195}
196
197pub fn importance_marks(importance: u8) -> String {
198 "⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
199}
200
201pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
202 let mut done = 0usize;
203 let mut total = 0usize;
204 for b in &task.body {
205 if let Block::Todo { done: d, .. } = b {
206 total += 1;
207 if *d {
208 done += 1;
209 }
210 }
211 }
212 (total > 0).then_some((done, total))
213}
214
215pub fn has_prose_or_image(task: &Task) -> bool {
216 task.body
217 .iter()
218 .any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
219}