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/// Whether `haystack` contains an already-normalized caseless search key.
46pub 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
59/// Sentinel category id for the "All Tasks" view. Not written to disk.
60pub const ALL_CATEGORY: &str = "";
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Task {
64    /// Stable identity.
65    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    /// Real category uuid, or `None` if uncategorized.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub category_id: Option<String>,
80    /// Stable label identities in the store's canonical label order.
81    #[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    /// A URL (or any link target) the user can open elsewhere.
123    Link {
124        #[serde(default)]
125        url: String,
126    },
127    Image {
128        /// Immutable content-addressed attachment ID. During form editing this
129        /// field may temporarily hold a source path; the store imports and
130        /// rewrites it before persistence.
131        #[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
186/// Whether a task title or textual description block contains a caseless key.
187/// Image attachment identities are storage metadata, not searchable task text.
188pub 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    /// In-memory only: the sidebar's "All Tasks" row.
218    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    /// Colors persisted by current databases and archives.
251    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    /// Colors offered for new labels and explicit color selection.
267    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 new_uuid() -> String {
339    uuid::Uuid::new_v4().to_string()
340}
341
342pub fn importance_marks(importance: u8) -> String {
343    "⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
344}
345
346pub const fn next_importance(importance: u8) -> u8 {
347    (importance + 1) % (MAX_IMPORTANCE + 1)
348}
349
350/// Unicode-normalized, case-folded identity for category names.
351pub fn category_name_key(value: &str) -> String {
352    caseless_key(value.trim())
353}
354
355/// Unicode-normalized, case-folded identity for label names.
356pub fn label_name_key(value: &str) -> String {
357    caseless_key(value.trim())
358}
359
360pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
361    let mut done = 0usize;
362    let mut total = 0usize;
363    for b in &task.description {
364        if let Block::Todo { done: d, .. } = b {
365            total += 1;
366            if *d {
367                done += 1;
368            }
369        }
370    }
371    (total > 0).then_some((done, total))
372}
373
374pub fn has_prose_or_image(task: &Task) -> bool {
375    task.description
376        .iter()
377        .any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
378}
379
380#[cfg(test)]
381mod tests {
382    use super::{LabelColor, MAX_LABEL_COLOR_LEN};
383
384    #[test]
385    fn label_colors_have_stable_storage_names_and_order() {
386        let names = LabelColor::ALL
387            .iter()
388            .map(ToString::to_string)
389            .collect::<Vec<_>>();
390        assert_eq!(
391            names,
392            [
393                "red", "orange", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo",
394                "purple", "pink", "brown",
395            ]
396        );
397        assert_eq!(
398            names.iter().map(|name| name.len()).max(),
399            Some(MAX_LABEL_COLOR_LEN)
400        );
401        for color in LabelColor::ALL {
402            assert_eq!(color.to_string().parse::<LabelColor>().unwrap(), color);
403        }
404        assert!("violet".parse::<LabelColor>().is_err());
405        assert!("gray".parse::<LabelColor>().is_err());
406        assert_eq!(
407            serde_json::to_string(&LabelColor::Brown).unwrap(),
408            r#""brown""#
409        );
410        assert_eq!(
411            LabelColor::SWATCHES,
412            [
413                LabelColor::Red,
414                LabelColor::Orange,
415                LabelColor::Yellow,
416                LabelColor::Lime,
417                LabelColor::Green,
418                LabelColor::Teal,
419                LabelColor::Cyan,
420                LabelColor::Blue,
421                LabelColor::Indigo,
422                LabelColor::Purple,
423                LabelColor::Pink,
424                LabelColor::Brown,
425            ]
426        );
427        assert_eq!(
428            LabelColor::automatic(LabelColor::SWATCHES.len()),
429            LabelColor::Red
430        );
431    }
432}