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
338/// Labels assigned to a task, in the store's canonical label order.
339pub fn labels_for_task<'a>(task: &Task, labels: &'a [Label]) -> impl Iterator<Item = &'a Label> {
340    labels
341        .iter()
342        .filter(|label| task.label_ids.contains(&label.id))
343}
344
345pub fn new_uuid() -> String {
346    uuid::Uuid::new_v4().to_string()
347}
348
349pub fn importance_marks(importance: u8) -> String {
350    "⚑".repeat(importance.min(MAX_IMPORTANCE) as usize)
351}
352
353pub const fn next_importance(importance: u8) -> u8 {
354    (importance + 1) % (MAX_IMPORTANCE + 1)
355}
356
357/// Unicode-normalized, case-folded identity for category names.
358pub fn category_name_key(value: &str) -> String {
359    caseless_key(value.trim())
360}
361
362/// Unicode-normalized, case-folded identity for label names.
363pub fn label_name_key(value: &str) -> String {
364    caseless_key(value.trim())
365}
366
367pub fn todo_progress(task: &Task) -> Option<(usize, usize)> {
368    let mut done = 0usize;
369    let mut total = 0usize;
370    for b in &task.description {
371        if let Block::Todo { done: d, .. } = b {
372            total += 1;
373            if *d {
374                done += 1;
375            }
376        }
377    }
378    (total > 0).then_some((done, total))
379}
380
381pub fn has_prose_or_image(task: &Task) -> bool {
382    task.description
383        .iter()
384        .any(|b| !matches!(b, Block::Todo { .. }) && !b.is_empty())
385}
386
387#[cfg(test)]
388mod tests {
389    use super::{LabelColor, MAX_LABEL_COLOR_LEN};
390
391    #[test]
392    fn label_colors_have_stable_storage_names_and_order() {
393        let names = LabelColor::ALL
394            .iter()
395            .map(ToString::to_string)
396            .collect::<Vec<_>>();
397        assert_eq!(
398            names,
399            [
400                "red", "orange", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo",
401                "purple", "pink", "brown",
402            ]
403        );
404        assert_eq!(
405            names.iter().map(|name| name.len()).max(),
406            Some(MAX_LABEL_COLOR_LEN)
407        );
408        for color in LabelColor::ALL {
409            assert_eq!(color.to_string().parse::<LabelColor>().unwrap(), color);
410        }
411        assert!("violet".parse::<LabelColor>().is_err());
412        assert!("gray".parse::<LabelColor>().is_err());
413        assert_eq!(
414            serde_json::to_string(&LabelColor::Brown).unwrap(),
415            r#""brown""#
416        );
417        assert_eq!(
418            LabelColor::SWATCHES,
419            [
420                LabelColor::Red,
421                LabelColor::Orange,
422                LabelColor::Yellow,
423                LabelColor::Lime,
424                LabelColor::Green,
425                LabelColor::Teal,
426                LabelColor::Cyan,
427                LabelColor::Blue,
428                LabelColor::Indigo,
429                LabelColor::Purple,
430                LabelColor::Pink,
431                LabelColor::Brown,
432            ]
433        );
434        assert_eq!(
435            LabelColor::automatic(LabelColor::SWATCHES.len()),
436            LabelColor::Red
437        );
438    }
439}