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 unicode_normalization::UnicodeNormalization;
11
12/// Legacy `tasks.json` / `categories.json` envelope schema accepted on import.
13pub 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
25/// Byte budget paired with a user-visible grapheme limit. This keeps a single
26/// grapheme with pathological combining sequences from bypassing every text
27/// length boundary while leaving generous room for emoji ZWJ sequences.
28pub fn text_byte_limit(max_graphemes: usize) -> usize {
29    max_graphemes.saturating_mul(16).max(256)
30}
31
32/// Stable Unicode compatibility-normalized, default-case-folded text used by
33/// every caseless identity and search path.
34pub fn caseless_key(value: &str) -> String {
35    value.nfkc().default_case_fold().nfkc().collect()
36}
37
38/// Sentinel category id for the "All Tasks" view. Not written to disk.
39pub const ALL_CATEGORY: &str = "";
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Task {
43    /// Stable identity.
44    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    /// Real category uuid, or `None` if uncategorized.
57    #[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    /// A URL (or any link target) the user can open elsewhere.
98    Link {
99        #[serde(default)]
100        url: String,
101    },
102    Image {
103        /// Immutable content-addressed attachment ID. During form editing this
104        /// field may temporarily hold a source path; the store imports and
105        /// rewrites it before persistence.
106        #[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    /// In-memory only: the sidebar's "All Tasks" row.
179    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}