Skip to main content

note_to_self_lib/
model.rs

1use chrono::{DateTime, NaiveDate, Utc};
2use serde::{Deserialize, Serialize};
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt;
6use std::str::FromStr;
7use uuid::Uuid;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct Entry {
11    pub id: Uuid,
12    pub created_at: DateTime<Utc>,
13    pub modified_at: DateTime<Utc>,
14    pub hlc: Hlc,
15    pub body: String,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub attachments: Vec<Attachment>,
18    pub tags: Vec<String>,
19    pub starred: bool,
20    pub kind: EntryKind,
21    pub deleted: bool,
22    pub version: u64,
23}
24
25impl Entry {
26    pub fn new_journal(body: String, explicit_tags: &[String], starred: bool, hlc: Hlc) -> Self {
27        Self::new_journal_with_attachments(body, explicit_tags, starred, Vec::new(), hlc)
28    }
29
30    pub fn new_journal_with_attachments(
31        body: String,
32        explicit_tags: &[String],
33        starred: bool,
34        attachments: Vec<Attachment>,
35        hlc: Hlc,
36    ) -> Self {
37        let now = Utc::now();
38        Self {
39            id: Uuid::now_v7(),
40            created_at: now,
41            modified_at: now,
42            hlc,
43            tags: merged_tags(&body, explicit_tags),
44            body,
45            attachments,
46            starred,
47            kind: EntryKind::Journal,
48            deleted: false,
49            version: 1,
50        }
51    }
52
53    pub fn new_todo(
54        body: String,
55        explicit_tags: &[String],
56        priority: Priority,
57        due: Option<NaiveDate>,
58        hlc: Hlc,
59    ) -> Self {
60        let now = Utc::now();
61        Self {
62            id: Uuid::now_v7(),
63            created_at: now,
64            modified_at: now,
65            hlc,
66            tags: merged_tags(&body, explicit_tags),
67            body,
68            attachments: Vec::new(),
69            starred: false,
70            kind: EntryKind::Todo(TodoMeta {
71                completed: false,
72                completed_at: None,
73                priority,
74                due,
75            }),
76            deleted: false,
77            version: 1,
78        }
79    }
80
81    pub fn touch(&mut self, hlc: Hlc) {
82        self.modified_at = Utc::now();
83        self.hlc = hlc;
84        self.version = self.version.saturating_add(1);
85        self.tags = merged_tags(&self.body, &[]);
86    }
87
88    pub fn short_id(&self) -> String {
89        self.id.to_string().chars().take(8).collect()
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct Attachment {
95    pub id: String,
96    pub file_name: String,
97    pub media_type: String,
98    pub data: String,
99    pub size: u64,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(tag = "type", content = "meta", rename_all = "snake_case")]
104pub enum EntryKind {
105    Journal,
106    Todo(TodoMeta),
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110pub struct TodoMeta {
111    pub completed: bool,
112    pub completed_at: Option<DateTime<Utc>>,
113    pub priority: Priority,
114    pub due: Option<NaiveDate>,
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
118#[serde(rename_all = "snake_case")]
119pub enum Priority {
120    Low,
121    Medium,
122    High,
123    Urgent,
124}
125
126impl Default for Priority {
127    fn default() -> Self {
128        Self::Medium
129    }
130}
131
132impl fmt::Display for Priority {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        let value = match self {
135            Self::Low => "low",
136            Self::Medium => "medium",
137            Self::High => "high",
138            Self::Urgent => "urgent",
139        };
140        f.write_str(value)
141    }
142}
143
144impl FromStr for Priority {
145    type Err = String;
146
147    fn from_str(value: &str) -> Result<Self, Self::Err> {
148        match value.to_ascii_lowercase().as_str() {
149            "low" | "l" => Ok(Self::Low),
150            "medium" | "med" | "m" => Ok(Self::Medium),
151            "high" | "h" => Ok(Self::High),
152            "urgent" | "u" => Ok(Self::Urgent),
153            other => Err(format!("unknown priority: {other}")),
154        }
155    }
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
159pub struct Hlc {
160    pub wall_ms: u64,
161    pub counter: u32,
162    pub node_id: u64,
163}
164
165impl Hlc {
166    pub fn new(node_id: u64) -> Self {
167        Self {
168            wall_ms: now_ms(),
169            counter: 0,
170            node_id,
171        }
172    }
173
174    pub fn tick(self) -> Self {
175        let now = now_ms();
176        if now > self.wall_ms {
177            Self {
178                wall_ms: now,
179                counter: 0,
180                node_id: self.node_id,
181            }
182        } else {
183            Self {
184                wall_ms: self.wall_ms,
185                counter: self.counter.saturating_add(1),
186                node_id: self.node_id,
187            }
188        }
189    }
190
191    pub fn observe(self, remote: Hlc) -> Self {
192        let now = now_ms();
193        let max_wall = self.wall_ms.max(remote.wall_ms).max(now);
194        let counter = if max_wall == self.wall_ms && max_wall == remote.wall_ms {
195            self.counter.max(remote.counter).saturating_add(1)
196        } else if max_wall == self.wall_ms {
197            self.counter.saturating_add(1)
198        } else if max_wall == remote.wall_ms {
199            remote.counter.saturating_add(1)
200        } else {
201            0
202        };
203
204        Self {
205            wall_ms: max_wall,
206            counter,
207            node_id: self.node_id,
208        }
209    }
210}
211
212impl Ord for Hlc {
213    fn cmp(&self, other: &Self) -> Ordering {
214        (self.wall_ms, self.counter, self.node_id).cmp(&(
215            other.wall_ms,
216            other.counter,
217            other.node_id,
218        ))
219    }
220}
221
222impl PartialOrd for Hlc {
223    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
224        Some(self.cmp(other))
225    }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229pub struct HlcState {
230    pub current: Hlc,
231}
232
233impl HlcState {
234    pub fn new(node_id: u64) -> Self {
235        Self {
236            current: Hlc::new(node_id),
237        }
238    }
239
240    pub fn next(&mut self) -> Hlc {
241        self.current = self.current.tick();
242        self.current
243    }
244
245    pub fn observe(&mut self, remote: Hlc) {
246        self.current = self.current.observe(remote);
247    }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251pub struct ManifestEntry {
252    pub version: u64,
253    pub hlc: Hlc,
254    pub checksum: String,
255}
256
257pub type Manifest = BTreeMap<Uuid, ManifestEntry>;
258
259pub fn extract_tags(body: &str) -> Vec<String> {
260    let mut tags = BTreeSet::new();
261    for token in
262        body.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == ')' || c == '(')
263    {
264        let Some(stripped) = token.strip_prefix('#') else {
265            continue;
266        };
267        let tag: String = stripped
268            .chars()
269            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
270            .collect::<String>()
271            .to_ascii_lowercase();
272        if !tag.is_empty() {
273            tags.insert(tag);
274        }
275    }
276    tags.into_iter().collect()
277}
278
279pub fn normalize_tag(tag: &str) -> Option<String> {
280    let tag = tag.trim().trim_start_matches('#').to_ascii_lowercase();
281    if tag.is_empty() {
282        None
283    } else {
284        Some(tag)
285    }
286}
287
288fn merged_tags(body: &str, explicit_tags: &[String]) -> Vec<String> {
289    let mut tags: BTreeSet<String> = extract_tags(body).into_iter().collect();
290    for tag in explicit_tags {
291        if let Some(tag) = normalize_tag(tag) {
292            tags.insert(tag);
293        }
294    }
295    tags.into_iter().collect()
296}
297
298fn now_ms() -> u64 {
299    Utc::now().timestamp_millis().max(0) as u64
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn hlc_orders_by_wall_counter_node() {
308        let a = Hlc {
309            wall_ms: 10,
310            counter: 1,
311            node_id: 1,
312        };
313        let b = Hlc {
314            wall_ms: 10,
315            counter: 2,
316            node_id: 0,
317        };
318        let c = Hlc {
319            wall_ms: 10,
320            counter: 2,
321            node_id: 9,
322        };
323        assert!(a < b);
324        assert!(b < c);
325    }
326
327    #[test]
328    fn extracts_hashtags_stably() {
329        assert_eq!(
330            extract_tags("hello #Work and #work, #rust-lang."),
331            vec!["rust-lang".to_string(), "work".to_string()]
332        );
333    }
334}