Skip to main content

archelon_core/
entry.rs

1use caretta_id::CarettaId;
2use chrono::NaiveDateTime;
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use crate::labels::{EntryFlag, entry_flags};
8
9/// Frontmatter metadata stored at the top of each .md file.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Frontmatter {
12    pub id: CarettaId,
13
14    /// Parent entry ID for hierarchical (bullet-journal nested) relationships.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub parent_id: Option<CarettaId>,
17
18    #[serde(default)]
19    pub title: String,
20
21    /// Optional slug override. If empty, the slug is derived from the title.
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub slug: String,
24
25    /// Timestamp when the entry was first created. Set automatically by `new`.
26    #[serde(default, with = "naive_datetime_serde")]
27    pub created_at: NaiveDateTime,
28
29    /// Timestamp of the last write. Updated automatically by `write_entry`.
30    #[serde(default, with = "naive_datetime_serde")]
31    pub updated_at: NaiveDateTime,
32
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub tags: Vec<String>,
35
36    /// Task metadata. Present only when this entry represents a task.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub task: Option<TaskMeta>,
39
40    /// Event metadata. Present only when this entry represents a calendar event.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub event: Option<EventMeta>,
43
44    /// Unknown frontmatter fields preserved for round-trip compatibility.
45    #[serde(flatten)]
46    pub extra: IndexMap<String, serde_yaml::Value>,
47}
48
49/// Task-specific metadata.
50///
51/// Conventional `status` values: `open`, `in_progress`, `done`, `cancelled`, `archived`.
52/// Any custom string is also accepted.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TaskMeta {
55    /// Due date/time.
56    #[serde(default, skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::eod::opt")]
57    pub due: Option<NaiveDateTime>,
58
59    /// Task status. Conventional values: open | in_progress | done | cancelled | archived
60    #[serde(default = "default_task_status")]
61    pub status: String,
62
63    /// Timestamp when the task was started (status → in_progress).
64    /// Set automatically by `entry modify`; can be overridden manually.
65    #[serde(default, skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::opt")]
66    pub started_at: Option<NaiveDateTime>,
67
68    /// Timestamp when the task was closed (status → done/cancelled/archived).
69    /// Set automatically by `entry modify`; can be overridden manually.
70    #[serde(default, skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::opt")]
71    pub closed_at: Option<NaiveDateTime>,
72
73    /// Unknown task fields preserved for round-trip compatibility.
74    #[serde(flatten)]
75    pub extra: IndexMap<String, serde_yaml::Value>,
76}
77
78fn default_task_status() -> String {
79    "open".to_owned()
80}
81
82/// Event-specific metadata.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EventMeta {
85    #[serde(with = "naive_datetime_serde")]
86    pub start: NaiveDateTime,
87    #[serde(with = "naive_datetime_serde::eod")]
88    pub end: NaiveDateTime,
89
90    /// Unknown event fields preserved for round-trip compatibility.
91    #[serde(flatten)]
92    pub extra: IndexMap<String, serde_yaml::Value>,
93}
94
95/// A single entry — one Markdown file in the journal.
96/// Tasks and notes coexist freely in the body (bullet-journal style).
97#[derive(Debug, Clone)]
98pub struct Entry {
99    /// Absolute path to the source .md file.
100    pub path: PathBuf,
101
102    /// Parsed frontmatter. Defaults to empty if the file has none.
103    pub frontmatter: Frontmatter,
104
105    /// Raw Markdown body (everything after the frontmatter block).
106    pub body: String,
107}
108
109impl Entry {
110    /// Returns the CarettaId from the frontmatter.
111    pub fn id(&self) -> CarettaId {
112        self.frontmatter.id
113    }
114
115    /// Returns the title: frontmatter title (if non-empty) → file stem → "(untitled)".
116    pub fn title(&self) -> &str {
117        return &self.frontmatter.title;
118    }
119}
120
121/// Read-only view of [`TaskMeta`] used for cache output and JSON serialization.
122///
123/// Unlike [`TaskMeta`], this type has no `extra` field and can derive [`Serialize`] cleanly.
124#[derive(Debug, Clone, Serialize)]
125pub struct TaskMetaView {
126    #[serde(skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::eod::opt")]
127    pub due: Option<NaiveDateTime>,
128    pub status: String,
129    #[serde(skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::opt")]
130    pub started_at: Option<NaiveDateTime>,
131    #[serde(skip_serializing_if = "Option::is_none", with = "naive_datetime_serde::opt")]
132    pub closed_at: Option<NaiveDateTime>,
133}
134
135impl From<TaskMeta> for TaskMetaView {
136    fn from(t: TaskMeta) -> Self {
137        TaskMetaView { due: t.due, status: t.status, started_at: t.started_at, closed_at: t.closed_at }
138    }
139}
140
141/// Read-only view of [`EventMeta`] used for cache output and JSON serialization.
142///
143/// Unlike [`EventMeta`], this type has no `extra` field and can derive [`Serialize`] cleanly.
144#[derive(Debug, Clone, Serialize)]
145pub struct EventMetaView {
146    #[serde(with = "naive_datetime_serde")]
147    pub start: NaiveDateTime,
148    #[serde(with = "naive_datetime_serde::eod")]
149    pub end: NaiveDateTime,
150}
151
152impl From<EventMeta> for EventMetaView {
153    fn from(e: EventMeta) -> Self {
154        EventMetaView { start: e.start, end: e.end }
155    }
156}
157
158/// Read-only view of [`Frontmatter`] used for cache output and JSON serialization.
159///
160/// Unlike [`Frontmatter`], this type has no `extra` field and can derive [`Serialize`] cleanly.
161/// `parent_id` is retained for internal use (e.g. tree building) but excluded from serialization.
162#[derive(Debug, Clone, Serialize)]
163pub struct FrontmatterView {
164    pub id: CarettaId,
165    #[serde(skip)]
166    pub parent_id: Option<CarettaId>,
167    pub title: String,
168    pub slug: String,
169    #[serde(with = "naive_datetime_serde")]
170    pub created_at: NaiveDateTime,
171    #[serde(with = "naive_datetime_serde")]
172    pub updated_at: NaiveDateTime,
173    pub tags: Vec<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub task: Option<TaskMetaView>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub event: Option<EventMetaView>,
178}
179
180impl From<Frontmatter> for FrontmatterView {
181    fn from(fm: Frontmatter) -> Self {
182        FrontmatterView {
183            id: fm.id,
184            parent_id: fm.parent_id,
185            title: fm.title,
186            slug: fm.slug,
187            created_at: fm.created_at,
188            updated_at: fm.updated_at,
189            tags: fm.tags,
190            task: fm.task.map(TaskMetaView::from),
191            event: fm.event.map(EventMetaView::from),
192        }
193    }
194}
195
196/// Metadata-only view of an entry — path, frontmatter, and computed flags without the body.
197///
198/// Returned by list operations to avoid loading large bodies into memory
199/// and to keep JSON output compact (e.g. for AI consumers).
200#[derive(Debug, Clone, Serialize)]
201pub struct EntryHeader {
202    pub path: String,
203    #[serde(flatten)]
204    pub frontmatter: FrontmatterView,
205    /// Computed status flags (type + freshness). Set at construction time.
206    pub flags: Vec<EntryFlag>,
207}
208
209impl EntryHeader {
210    pub fn id(&self) -> CarettaId {
211        self.frontmatter.id
212    }
213
214    pub fn title(&self) -> &str {
215        &self.frontmatter.title
216    }
217}
218
219impl From<Entry> for EntryHeader {
220    fn from(entry: Entry) -> Self {
221        let fm = FrontmatterView::from(entry.frontmatter);
222        let flags = entry_flags(fm.task.as_ref(), fm.event.as_ref(), fm.created_at, fm.updated_at);
223        EntryHeader { path: entry.path.to_string_lossy().into_owned(), frontmatter: fm, flags }
224    }
225}
226
227/// Custom serde module for `NaiveDateTime` using minute-precision format (`YYYY-MM-DDTHH:MM`).
228///
229/// Serializes to `%Y-%m-%dT%H:%M`. Deserializes from:
230/// - `%Y-%m-%dT%H:%M`        (minute precision — preferred)
231/// - `%Y-%m-%dT%H:%M:%S`     (second precision)
232/// - `%Y-%m-%dT%H:%M:%S%.f`  (sub-second precision — for backward compat)
233/// - `%Y-%m-%d`              (date only — `00:00` for start fields, `23:59` via `eod` sub-module)
234mod naive_datetime_serde {
235    use chrono::{NaiveDate, NaiveDateTime};
236    use serde::{Deserialize, Deserializer, Serializer};
237
238    const FORMAT: &str = "%Y-%m-%dT%H:%M";
239
240    /// Parse a datetime string, using `(h, m)` as the fallback time when only a date is given.
241    pub(super) fn parse_with_fallback(s: &str, h: u32, m: u32) -> Result<NaiveDateTime, String> {
242        for fmt in [FORMAT, "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%.f"] {
243            if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
244                return Ok(dt);
245            }
246        }
247        if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
248            return Ok(d.and_hms_opt(h, m, 0).unwrap());
249        }
250        Err(format!(
251            "cannot parse `{s}` as a datetime; expected format YYYY-MM-DDTHH:MM"
252        ))
253    }
254
255    pub fn serialize<S: Serializer>(dt: &NaiveDateTime, s: S) -> Result<S::Ok, S::Error> {
256        s.serialize_str(&dt.format(FORMAT).to_string())
257    }
258
259    /// Deserializes with date-only → `00:00` (start-of-day).
260    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<NaiveDateTime, D::Error> {
261        let raw = String::deserialize(d)?;
262        parse_with_fallback(&raw, 0, 0).map_err(serde::de::Error::custom)
263    }
264
265    /// For `Option<NaiveDateTime>` fields — date-only → `00:00`.
266    pub mod opt {
267        use chrono::NaiveDateTime;
268        use serde::{Deserialize, Deserializer, Serializer};
269
270        pub fn serialize<S: Serializer>(
271            opt: &Option<NaiveDateTime>,
272            s: S,
273        ) -> Result<S::Ok, S::Error> {
274            match opt {
275                Some(dt) => super::serialize(dt, s),
276                None => s.serialize_none(),
277            }
278        }
279
280        pub fn deserialize<'de, D: Deserializer<'de>>(
281            d: D,
282        ) -> Result<Option<NaiveDateTime>, D::Error> {
283            let raw = String::deserialize(d)?;
284            super::parse_with_fallback(&raw, 0, 0).map(Some).map_err(serde::de::Error::custom)
285        }
286    }
287
288    /// Variant where date-only → `23:59` (end-of-day). Used for due dates and event end times.
289    pub mod eod {
290        use chrono::NaiveDateTime;
291        use serde::{Deserialize, Deserializer, Serializer};
292
293        pub fn serialize<S: Serializer>(dt: &NaiveDateTime, s: S) -> Result<S::Ok, S::Error> {
294            super::serialize(dt, s)
295        }
296
297        pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<NaiveDateTime, D::Error> {
298            let raw = String::deserialize(d)?;
299            super::parse_with_fallback(&raw, 23, 59).map_err(serde::de::Error::custom)
300        }
301
302        pub mod opt {
303            use chrono::NaiveDateTime;
304            use serde::{Deserialize, Deserializer, Serializer};
305
306            pub fn serialize<S: Serializer>(
307                opt: &Option<NaiveDateTime>,
308                s: S,
309            ) -> Result<S::Ok, S::Error> {
310                match opt {
311                    Some(dt) => super::serialize(dt, s),
312                    None => s.serialize_none(),
313                }
314            }
315
316            pub fn deserialize<'de, D: Deserializer<'de>>(
317                d: D,
318            ) -> Result<Option<NaiveDateTime>, D::Error> {
319                let raw = String::deserialize(d)?;
320                super::super::parse_with_fallback(&raw, 23, 59)
321                    .map(Some)
322                    .map_err(serde::de::Error::custom)
323            }
324        }
325    }
326}