Skip to main content

mach/
model.rs

1//! Task and category types for the local todo store.
2//!
3//! SQLite is the current on-disk format. [`SCHEMA_VERSION`] identifies only
4//! the legacy JSON envelopes accepted by the one-time importer. "All Tasks"
5//! is never stored — it is a UI view over every task.
6
7use caseless::Caseless;
8use chrono::Local;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::str::FromStr;
12use unicode_normalization::UnicodeNormalization;
13
14/// Legacy `tasks.json` / `categories.json` envelope schema accepted on import.
15pub 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
31/// Byte budget paired with a user-visible grapheme limit. This keeps a single
32/// grapheme with pathological combining sequences from bypassing every text
33/// length boundary while leaving generous room for emoji ZWJ sequences.
34pub 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
39/// Stable Unicode compatibility-normalized, default-case-folded text used by
40/// every caseless identity and search path.
41pub fn caseless_key(value: &str) -> String {
42    value.nfkc().default_case_fold().nfkc().collect()
43}
44
45/// Sentinel category id for the "All Tasks" view. Not written to disk.
46pub const ALL_CATEGORY: &str = "";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct Task {
50    /// Stable identity.
51    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    /// Real category uuid, or `None` if uncategorized.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub category_id: Option<String>,
66    /// Stable label identities in the store's canonical label order.
67    #[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    /// A URL (or any link target) the user can open elsewhere.
109    Link {
110        #[serde(default)]
111        url: String,
112    },
113    Image {
114        /// Immutable content-addressed attachment ID. During form editing this
115        /// field may temporarily hold a source path; the store imports and
116        /// rewrites it before persistence.
117        #[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    /// In-memory only: the sidebar's "All Tasks" row.
190    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    /// Colors persisted by current databases and archives.
223    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    /// Colors offered for new labels and explicit color selection.
239    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
322/// Unicode-normalized, case-folded identity for category names.
323pub fn category_name_key(value: &str) -> String {
324    caseless_key(value.trim())
325}
326
327/// Unicode-normalized, case-folded identity for label names.
328pub 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}