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