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}