Skip to main content

docgen_bases/
note.rs

1//! The corpus: the set of notes a base queries over. Each [`Note`] carries its
2//! frontmatter properties (as typed [`Value`]s) plus file metadata (`file.*`).
3//! The caller (docgen-build / docgen-core) constructs these from discovered docs;
4//! this crate stays free of any filesystem or docgen-specific types.
5
6use std::collections::BTreeMap;
7
8use crate::value::{BaseDate, BaseLink, Value};
9
10/// One note (markdown file) in the vault, queryable by a base.
11#[derive(Debug, Clone, Default)]
12pub struct Note {
13    /// Frontmatter properties, keyed by name, coerced to typed values. Accessed
14    /// via `note.<name>` or a bare `<name>` in expressions.
15    pub properties: BTreeMap<String, Value>,
16    // --- file.* metadata ---
17    /// `file.name` — filename with extension (e.g. `Intro.md`).
18    pub name: String,
19    /// `file.basename` — filename without extension.
20    pub basename: String,
21    /// `file.path` — full vault-relative path (e.g. `guide/Intro.md`).
22    pub path: String,
23    /// `file.folder` — containing folder path (empty for root).
24    pub folder: String,
25    /// `file.ext` — extension without the dot (`md`).
26    pub ext: String,
27    /// `file.size` — byte length of the source file.
28    pub size: u64,
29    /// `file.ctime` — creation time, if known.
30    pub ctime: Option<BaseDate>,
31    /// `file.mtime` — modification time, if known.
32    pub mtime: Option<BaseDate>,
33    /// `file.tags` — all `#tags` (frontmatter + body), without the leading `#`.
34    pub tags: Vec<String>,
35    /// `file.links` — outbound internal link targets.
36    pub links: Vec<BaseLink>,
37    /// The doc's site slug (docgen-specific; used to build the cell hyperlink to
38    /// the rendered page). Not exposed as a base property.
39    pub slug: String,
40    /// The note's rendered body HTML, surfaced in a card via a `note.body` column.
41    /// Populated by the host only for `.base` *pages* (rendered after the document
42    /// pass); empty inside inline ```base blocks, which render before bodies exist.
43    pub body: String,
44}
45
46impl Note {
47    /// Resolve a `file.<field>` access to a value. Unknown fields → `Null`.
48    pub fn file_property(&self, field: &str) -> Value {
49        match field {
50            "name" => Value::Str(self.name.clone()),
51            "basename" => Value::Str(self.basename.clone()),
52            "path" => Value::Str(self.path.clone()),
53            "folder" => Value::Str(self.folder.clone()),
54            "ext" => Value::Str(self.ext.clone()),
55            "size" => Value::Number(self.size as f64),
56            "ctime" => self.ctime.map(Value::Date).unwrap_or(Value::Null),
57            "mtime" => self.mtime.map(Value::Date).unwrap_or(Value::Null),
58            "tags" => Value::List(self.tags.iter().cloned().map(Value::Str).collect()),
59            "links" => Value::List(self.links.iter().cloned().map(Value::Link).collect()),
60            // `file.file` / `file.properties` reflect the note itself.
61            "properties" => Value::Object(self.properties.clone()),
62            _ => Value::Null,
63        }
64    }
65
66    /// Resolve a bare/`note.` property. Unknown → `Null`.
67    pub fn note_property(&self, name: &str) -> Value {
68        self.properties.get(name).cloned().unwrap_or(Value::Null)
69    }
70
71    /// Whether this note has any of `tags` (Obsidian's `file.hasTag` matches a tag
72    /// or any of its parents: a note tagged `#a/b` matches `hasTag("a")`).
73    pub fn has_tag(&self, want: &str) -> bool {
74        let want = want.trim_start_matches('#');
75        self.tags
76            .iter()
77            .any(|t| t == want || t.starts_with(&format!("{want}/")))
78    }
79
80    /// Whether this note lives in `folder` or any subfolder of it.
81    pub fn in_folder(&self, folder: &str) -> bool {
82        let folder = folder.trim_matches('/');
83        if folder.is_empty() {
84            return true;
85        }
86        self.folder == folder || self.folder.starts_with(&format!("{folder}/"))
87    }
88
89    /// Whether this note links to `target` (by basename/path).
90    pub fn has_link(&self, target: &BaseLink) -> bool {
91        self.links.iter().any(|l| l.same_target(target))
92    }
93}
94
95/// The whole set of notes a base evaluates against.
96#[derive(Debug, Clone, Default)]
97pub struct Corpus {
98    pub notes: Vec<Note>,
99}
100
101impl Corpus {
102    pub fn new(notes: Vec<Note>) -> Self {
103        Self { notes }
104    }
105
106    /// Notes that link to `target` — backs `file.backlinks` / `linksTo`.
107    pub fn backlinks_to(&self, target: &BaseLink) -> Vec<&Note> {
108        self.notes.iter().filter(|n| n.has_link(target)).collect()
109    }
110}
111
112/// Convert a parsed YAML frontmatter mapping (`serde_yml::Value`) into typed base
113/// properties. Strings that look like `[[wikilinks]]` become [`Value::Link`];
114/// `YYYY-MM-DD[ HH:mm[:ss]]` strings become [`Value::Date`]; sequences become
115/// lists; nested maps become objects. This is the bridge from docgen's
116/// frontmatter to the base value model.
117pub fn properties_from_yaml(fm: &serde_yml::Value) -> BTreeMap<String, Value> {
118    let mut out = BTreeMap::new();
119    if let serde_yml::Value::Mapping(map) = fm {
120        // serde_yml (noyalib shim) mappings are String-keyed.
121        for (k, v) in map {
122            out.insert(k.to_string(), value_from_yaml(v));
123        }
124    }
125    out
126}
127
128/// Convert a single YAML node into a base [`Value`], inferring dates and links
129/// from string scalars.
130pub fn value_from_yaml(v: &serde_yml::Value) -> Value {
131    match v {
132        serde_yml::Value::Null => Value::Null,
133        serde_yml::Value::Bool(b) => Value::Bool(*b),
134        serde_yml::Value::Number(_) => v.as_f64().map(Value::Number).unwrap_or(Value::Null),
135        serde_yml::Value::String(s) => scalar_string_to_value(s),
136        serde_yml::Value::Sequence(items) => {
137            Value::List(items.iter().map(value_from_yaml).collect())
138        }
139        serde_yml::Value::Mapping(map) => {
140            let mut obj = BTreeMap::new();
141            for (k, val) in map {
142                obj.insert(k.to_string(), value_from_yaml(val));
143            }
144            Value::Object(obj)
145        }
146        // Tagged scalars (e.g. `!Custom foo`): treat as their string form.
147        other => other
148            .as_str()
149            .map(scalar_string_to_value)
150            .unwrap_or(Value::Null),
151    }
152}
153
154/// Infer a value from a YAML string scalar: `[[wikilink]]`/`[[a|b]]` → link,
155/// ISO-ish date → date, otherwise a plain string.
156pub fn scalar_string_to_value(s: &str) -> Value {
157    let trimmed = s.trim();
158    if let Some(link) = parse_wikilink(trimmed) {
159        return Value::Link(link);
160    }
161    if let Some(date) = parse_date(trimmed) {
162        return Value::Date(date);
163    }
164    Value::Str(s.to_string())
165}
166
167/// Parse a `[[target]]` or `[[target|display]]` wikilink string. Returns `None`
168/// if the string isn't a single wikilink.
169pub fn parse_wikilink(s: &str) -> Option<BaseLink> {
170    let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
171    if inner.contains("[[") {
172        return None; // not a single link
173    }
174    let (target, display) = match inner.split_once('|') {
175        Some((t, d)) => (t.trim(), Some(d.trim().to_string())),
176        None => (inner.trim(), None),
177    };
178    // Drop any `#heading` / `^block` anchor from the target.
179    let target = target.split(['#', '^']).next().unwrap_or(target).trim();
180    let target = target.strip_suffix(".md").unwrap_or(target);
181    Some(BaseLink {
182        path: target.to_string(),
183        display,
184    })
185}
186
187/// Parse a `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm[:ss]`, or `YYYY-MM-DD HH:mm[:ss]`
188/// string into a [`BaseDate`]. Lenient but strict enough not to swallow arbitrary
189/// hyphenated text. Returns `None` when the shape doesn't match.
190pub fn parse_date(s: &str) -> Option<BaseDate> {
191    let s = s.trim();
192    let (date_part, time_part) = match s.split_once(['T', ' ']) {
193        Some((d, t)) => (d, Some(t)),
194        None => (s, None),
195    };
196    let mut dp = date_part.split('-');
197    let year: i64 = dp.next()?.parse().ok()?;
198    let month: u32 = dp.next()?.parse().ok()?;
199    let day: u32 = dp.next()?.parse().ok()?;
200    if dp.next().is_some() || !(1..=12).contains(&month) {
201        return None;
202    }
203    // Reject impossible calendar dates (e.g. Feb 30, Apr 31): validate the day
204    // against the month's actual length so `epoch_millis` never aliases an
205    // out-of-range day onto a different real date.
206    if day < 1 || day > days_in_month(year, month) {
207        return None;
208    }
209    // A plausible year band guards against matching things like `12-34-56` ids.
210    if !(1000..=9999).contains(&year) {
211        return None;
212    }
213    let (mut hour, mut minute, mut second, mut millisecond, has_time) =
214        (0u32, 0u32, 0u32, 0u32, time_part.is_some());
215    if let Some(t) = time_part {
216        // Strip a trailing timezone (`Z` or `±hh:mm`) — dates are treated as naive.
217        // A time never contains `+`/`-` itself, so splitting on them isolates the
218        // clock portion for both positive and negative offsets.
219        let t = t.trim().trim_end_matches('Z');
220        let t = t.split(['+', '-']).next().unwrap_or(t);
221        let mut tp = t.split(':');
222        hour = tp.next()?.trim().parse().ok()?;
223        minute = tp.next().unwrap_or("0").parse().ok()?;
224        // Seconds may carry a fractional part (`ss.SSS`); split it into millis.
225        let sec_field = tp.next().unwrap_or("0");
226        let (sec_str, ms) = match sec_field.split_once('.') {
227            Some((s, frac)) => {
228                // Take up to 3 fractional digits, right-padded, as milliseconds.
229                let mut f = frac.chars().take(3).collect::<String>();
230                while f.len() < 3 {
231                    f.push('0');
232                }
233                (s, f.parse::<u32>().ok()?)
234            }
235            None => (sec_field, 0),
236        };
237        second = sec_str.parse().ok()?;
238        millisecond = ms;
239        if hour > 23 || minute > 59 || second > 59 {
240            return None;
241        }
242    }
243    Some(BaseDate {
244        year,
245        month,
246        day,
247        hour,
248        minute,
249        second,
250        millisecond,
251        has_time,
252    })
253}
254
255/// Days in `month` (1-12) of `year`, accounting for leap years (proleptic
256/// Gregorian).
257fn days_in_month(year: i64, month: u32) -> u32 {
258    match month {
259        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
260        4 | 6 | 9 | 11 => 30,
261        2 => {
262            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
263            if leap {
264                29
265            } else {
266                28
267            }
268        }
269        _ => 0,
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn wikilink_parsing() {
279        let l = parse_wikilink("[[Books]]").unwrap();
280        assert_eq!(l.path, "Books");
281        assert_eq!(l.display, None);
282        let l2 = parse_wikilink("[[Categories/Books|Books]]").unwrap();
283        assert_eq!(l2.path, "Categories/Books");
284        assert_eq!(l2.display.as_deref(), Some("Books"));
285        assert!(parse_wikilink("not a link").is_none());
286        // Anchor stripped.
287        assert_eq!(parse_wikilink("[[Note#Heading]]").unwrap().path, "Note");
288    }
289
290    #[test]
291    fn date_parsing() {
292        let d = parse_date("2024-01-02").unwrap();
293        assert_eq!((d.year, d.month, d.day), (2024, 1, 2));
294        assert!(!d.has_time);
295        let dt = parse_date("2024-01-02 15:04:05").unwrap();
296        assert_eq!((dt.hour, dt.minute, dt.second), (15, 4, 5));
297        assert!(dt.has_time);
298        let iso = parse_date("2024-01-02T15:04").unwrap();
299        assert_eq!((iso.hour, iso.minute), (15, 4));
300        // Non-dates.
301        assert!(parse_date("hello").is_none());
302        assert!(parse_date("12-34-56").is_none());
303        assert!(parse_date("2024-13-01").is_none());
304    }
305
306    #[test]
307    fn parse_date_handles_timezones_and_fractions() {
308        // Negative offset (Western timezone) must parse, not degrade to a string.
309        let d = parse_date("2024-01-02T15:04:05-05:00").unwrap();
310        assert_eq!((d.hour, d.minute, d.second), (15, 4, 5));
311        // Positive offset and Z too.
312        assert!(parse_date("2024-01-02T15:04:05+05:30").is_some());
313        assert!(parse_date("2024-01-02T15:04:05Z").is_some());
314        // Fractional seconds → milliseconds.
315        let f = parse_date("2024-01-02T15:04:05.123").unwrap();
316        assert_eq!(f.millisecond, 123);
317        assert_eq!(f.second, 5);
318    }
319
320    #[test]
321    fn parse_date_rejects_impossible_calendar_dates() {
322        assert!(parse_date("2024-02-30").is_none()); // Feb never has 30
323        assert!(parse_date("2023-02-29").is_none()); // 2023 not a leap year
324        assert!(parse_date("2024-02-29").is_some()); // 2024 is a leap year
325        assert!(parse_date("2024-04-31").is_none()); // April has 30 days
326        assert!(parse_date("2024-04-30").is_some());
327    }
328
329    #[test]
330    fn yaml_to_values_infers_types() {
331        let fm: serde_yml::Value = serde_yml::from_str(
332            "title: Hello\ncount: 3\ndone: true\ncats:\n  - \"[[Categories/Books|Books]]\"\ncreated: 2024-05-06\n",
333        )
334        .unwrap();
335        let props = properties_from_yaml(&fm);
336        assert!(matches!(props.get("title"), Some(Value::Str(s)) if s == "Hello"));
337        assert!(matches!(props.get("count"), Some(Value::Number(n)) if *n == 3.0));
338        assert!(matches!(props.get("done"), Some(Value::Bool(true))));
339        match props.get("cats") {
340            Some(Value::List(items)) => {
341                assert!(matches!(&items[0], Value::Link(l) if l.path == "Categories/Books"));
342            }
343            other => panic!("expected list, got {other:?}"),
344        }
345        // A bare YAML date scalar is parsed by serde_yml as a string here.
346        assert!(matches!(props.get("created"), Some(Value::Date(_))));
347    }
348
349    #[test]
350    fn has_tag_matches_parents() {
351        let mut n = Note::default();
352        n.tags = vec!["project/active".into()];
353        assert!(n.has_tag("project"));
354        assert!(n.has_tag("project/active"));
355        assert!(n.has_tag("#project"));
356        assert!(!n.has_tag("proj"));
357    }
358
359    #[test]
360    fn in_folder_matches_subfolders() {
361        let mut n = Note::default();
362        n.folder = "Home/Things".into();
363        assert!(n.in_folder("Home"));
364        assert!(n.in_folder("Home/Things"));
365        assert!(!n.in_folder("Misc"));
366        assert!(n.in_folder("")); // vault root matches all
367    }
368}