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