Skip to main content

docgen_bases/
model.rs

1//! Serde model for a `.base` YAML file. Deserialization is deliberately tolerant:
2//! unknown keys are ignored, every section is optional, and the filter tree
3//! accepts either bare expression strings or nested `and`/`or`/`not` maps.
4
5use std::collections::BTreeMap;
6
7use serde::Deserialize;
8
9/// A parsed `.base` file.
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(default)]
12pub struct BaseFile {
13    /// docgen-specific (Obsidian-ignored): the page title for a standalone `.base`
14    /// file. `None` falls back to the file's name. Lets a base whose file name is
15    /// a slug (e.g. `releases.base`) present a proper title ("Releases").
16    pub title: Option<String>,
17    /// Global filter tree applied to every view.
18    pub filters: Option<Filter>,
19    /// Named formulas: name → expression string.
20    pub formulas: BTreeMap<String, String>,
21    /// Per-property display configuration.
22    pub properties: BTreeMap<String, PropertyConfig>,
23    /// Named custom summary expressions.
24    pub summaries: BTreeMap<String, String>,
25    /// The views to render, in order.
26    pub views: Vec<View>,
27    /// docgen-specific (Obsidian-ignored): a bare bool that enables/disables the
28    /// interactive island for the whole base. `false` = force pure static.
29    #[serde(rename = "docgenInteractive")]
30    pub docgen_interactive: Option<InteractiveToggle>,
31}
32
33/// A bare boolean toggle (`docgenInteractive: false`) at the base level.
34#[derive(Debug, Clone, Copy, Deserialize)]
35#[serde(transparent)]
36pub struct InteractiveToggle(pub bool);
37
38impl InteractiveToggle {
39    pub fn enabled(&self) -> bool {
40        self.0
41    }
42}
43
44/// docgen-specific per-view interactive overrides (`docgenInteractive: { ... }`).
45/// Everything is optional and Obsidian-tolerant (unknown keys ignored).
46#[derive(Debug, Clone, Default, Deserialize)]
47#[serde(default)]
48pub struct ViewInteractive {
49    /// Explicitly enable/disable the island for this view (M3 host gating).
50    pub enabled: Option<bool>,
51    /// Show the free-text search box (default: true).
52    pub search: Option<bool>,
53    /// Rows per page (`pageSize`). 0 = no pagination.
54    #[serde(rename = "pageSize")]
55    pub page_size: Option<usize>,
56    /// Enum-vs-text cardinality threshold (`maxEnum`, default 40).
57    #[serde(rename = "maxEnum")]
58    pub max_enum: Option<usize>,
59    /// Per-column filter widget override: col → `none|text|enum|date|number|boolean`.
60    pub filters: BTreeMap<String, String>,
61    /// Per-column sortable override: col → bool.
62    pub sortable: BTreeMap<String, bool>,
63    /// Per-column sort-order override: col → `text|semver`.
64    ///
65    /// A column whose values all parse as versions sorts as versions
66    /// automatically; `text` opts back out to plain string order, and `semver`
67    /// forces version order on a column that would not be detected (values that
68    /// then fail to parse sort last). Unlike the rest of this struct, this also
69    /// applies to the static build — a view's `sort:` is a build-time ordering.
70    #[serde(rename = "sortAs")]
71    pub sort_as: BTreeMap<String, String>,
72    /// Initial sort override (`defaultSort`).
73    #[serde(rename = "defaultSort")]
74    pub default_sort: Vec<SortKey>,
75    /// Any key here docgen does not recognize. Unlike the Obsidian-owned parts of
76    /// the format — where an unknown property is forgiving by design — this block
77    /// is docgen's OWN namespace, so a key in it that docgen ignores is always an
78    /// author mistake. Captured rather than dropped so the renderer can say so;
79    /// see `ViewInteractive::unknown_key_warning`.
80    #[serde(flatten)]
81    pub unknown: BTreeMap<String, serde_yml::Value>,
82}
83
84impl ViewInteractive {
85    /// Every key docgen understands here, for diagnostics.
86    const KNOWN: &'static [&'static str] = &[
87        "enabled",
88        "search",
89        "pageSize",
90        "maxEnum",
91        "filters",
92        "sortable",
93        "sortAs",
94        "defaultSort",
95    ];
96
97    /// A human message naming every unrecognized key, or `None` when clean.
98    ///
99    /// These keys are camelCase, so the overwhelmingly likely typo is a case slip
100    /// (`pagesize`, `defaultsort`) — match case-insensitively to name the intended
101    /// key. Anything else is reported without a guess rather than with a wrong one.
102    pub fn unknown_key_warning(&self) -> Option<String> {
103        if self.unknown.is_empty() {
104            return None;
105        }
106        let parts: Vec<String> = self
107            .unknown
108            .keys()
109            .map(|k| {
110                match Self::KNOWN
111                    .iter()
112                    .find(|known| known.eq_ignore_ascii_case(k))
113                {
114                    Some(known) => format!("`{k}` (did you mean `{known}`?)"),
115                    None => format!("`{k}`"),
116                }
117            })
118            .collect();
119        Some(format!(
120            "unknown docgenInteractive key: {}",
121            parts.join(", ")
122        ))
123    }
124}
125
126/// Per-property display config (`properties.<key>.displayName`).
127#[derive(Debug, Clone, Default, Deserialize)]
128#[serde(default)]
129pub struct PropertyConfig {
130    #[serde(rename = "displayName")]
131    pub display_name: Option<String>,
132}
133
134/// A filter node: a leaf expression string, or a logical combinator over
135/// sub-filters. Obsidian writes these as `- expr` list items and
136/// `and:`/`or:`/`not:` maps.
137#[derive(Debug, Clone, Deserialize)]
138#[serde(untagged)]
139pub enum Filter {
140    /// A single expression string (e.g. `file.hasTag("book")`).
141    Expr(String),
142    /// A logical combinator. Exactly one of `and`/`or`/`not` is expected, but the
143    /// struct tolerates any subset (missing = absent).
144    Logic(LogicFilter),
145    /// A bare list of filters is treated as an implicit `and`.
146    List(Vec<Filter>),
147}
148
149#[derive(Debug, Clone, Default, Deserialize)]
150#[serde(default)]
151pub struct LogicFilter {
152    pub and: Option<Vec<Filter>>,
153    pub or: Option<Vec<Filter>>,
154    /// `not` accepts either a single filter or a list (all negated & AND-ed).
155    pub not: Option<Box<NotFilter>>,
156}
157
158/// `not:` may hold a single filter or a list of them.
159#[derive(Debug, Clone, Deserialize)]
160#[serde(untagged)]
161pub enum NotFilter {
162    One(Filter),
163    Many(Vec<Filter>),
164}
165
166/// A single view configuration.
167#[derive(Debug, Clone, Default, Deserialize)]
168#[serde(default)]
169pub struct View {
170    /// `table` (default), `cards`, or `list`.
171    #[serde(rename = "type")]
172    pub view_type: String,
173    /// Display name (view tab label / section heading).
174    pub name: Option<String>,
175    /// View-level filter, AND-combined with the global filter.
176    pub filters: Option<Filter>,
177    /// Columns to show and their order (property references). Empty = infer from
178    /// the data (all note properties seen, plus `file.name`).
179    pub order: Vec<String>,
180    /// Row sort keys, applied in order.
181    pub sort: Vec<SortKey>,
182    /// Grouping (rows partitioned by this property).
183    #[serde(rename = "groupBy")]
184    pub group_by: Option<GroupBy>,
185    /// Row cap.
186    pub limit: Option<usize>,
187    /// Per-column pixel widths (`columnSize.<prop> = 381`).
188    #[serde(rename = "columnSize")]
189    pub column_size: BTreeMap<String, u32>,
190    /// Per-column summary function name (`summaries.<prop> = Average`).
191    pub summaries: BTreeMap<String, String>,
192    /// Cards view: property whose value (image/url) is the card cover.
193    pub image: Option<String>,
194    /// docgen-specific (Obsidian-ignored) interactive overrides for this view.
195    #[serde(rename = "docgenInteractive")]
196    pub interactive: Option<ViewInteractive>,
197}
198
199/// A sort key: which property, ascending or descending.
200#[derive(Debug, Clone, Deserialize)]
201#[serde(untagged)]
202pub enum SortKey {
203    /// Shorthand: just a property name (ascending).
204    Property(String),
205    /// Full form: `{ property: file.name, direction: DESC }`.
206    Full {
207        property: String,
208        #[serde(default)]
209        direction: Option<String>,
210    },
211}
212
213impl SortKey {
214    pub fn property(&self) -> &str {
215        match self {
216            SortKey::Property(p) => p,
217            SortKey::Full { property, .. } => property,
218        }
219    }
220
221    /// True when this key sorts descending.
222    pub fn descending(&self) -> bool {
223        match self {
224            SortKey::Property(_) => false,
225            SortKey::Full { direction, .. } => direction
226                .as_deref()
227                .map(|d| d.eq_ignore_ascii_case("desc"))
228                .unwrap_or(false),
229        }
230    }
231}
232
233/// `groupBy: { property, direction }`.
234#[derive(Debug, Clone, Deserialize)]
235#[serde(untagged)]
236pub enum GroupBy {
237    Property(String),
238    Full {
239        property: String,
240        #[serde(default)]
241        direction: Option<String>,
242    },
243}
244
245impl GroupBy {
246    pub fn property(&self) -> &str {
247        match self {
248            GroupBy::Property(p) => p,
249            GroupBy::Full { property, .. } => property,
250        }
251    }
252
253    pub fn descending(&self) -> bool {
254        match self {
255            GroupBy::Property(_) => false,
256            GroupBy::Full { direction, .. } => direction
257                .as_deref()
258                .map(|d| d.eq_ignore_ascii_case("desc"))
259                .unwrap_or(false),
260        }
261    }
262}
263
264/// Parse a `.base` YAML document. Returns a detailed error on malformed YAML.
265pub fn parse_base(yaml: &str) -> Result<BaseFile, serde_yml::Error> {
266    // An empty document is a valid (empty) base.
267    if yaml.trim().is_empty() {
268        return Ok(BaseFile::default());
269    }
270    serde_yml::from_str(yaml)
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn parses_real_vault_shape() {
279        let yaml = r#"
280filters:
281  and:
282    - categories.contains(link("Categories/Books", "Books"))
283    - '!file.inFolder("Misc")'
284views:
285  - type: table
286    name: Table
287    order:
288      - file.name
289      - file.size
290    sort:
291      - property: file.name
292        direction: DESC
293    columnSize:
294      file.name: 381
295"#;
296        let base = parse_base(yaml).unwrap();
297        assert!(base.filters.is_some());
298        assert_eq!(base.views.len(), 1);
299        let v = &base.views[0];
300        assert_eq!(v.view_type, "table");
301        assert_eq!(v.name.as_deref(), Some("Table"));
302        assert_eq!(v.order, vec!["file.name", "file.size"]);
303        assert_eq!(v.sort.len(), 1);
304        assert_eq!(v.sort[0].property(), "file.name");
305        assert!(v.sort[0].descending());
306        assert_eq!(v.column_size.get("file.name"), Some(&381));
307    }
308
309    #[test]
310    fn parses_formulas_and_summaries() {
311        let yaml = r#"
312formulas:
313  formatted_price: 'if(price, price.toFixed(2) + " dollars")'
314  ppu: "(price / age).toFixed(2)"
315summaries:
316  customAverage: 'values.mean().round(3)'
317properties:
318  status:
319    displayName: Status
320views:
321  - type: table
322    summaries:
323      formula.ppu: Average
324"#;
325        let base = parse_base(yaml).unwrap();
326        assert_eq!(base.formulas.len(), 2);
327        assert!(base.formulas.contains_key("ppu"));
328        assert_eq!(
329            base.summaries.get("customAverage").map(String::as_str),
330            Some("values.mean().round(3)")
331        );
332        assert_eq!(
333            base.properties
334                .get("status")
335                .and_then(|p| p.display_name.as_deref()),
336            Some("Status")
337        );
338        assert_eq!(
339            base.views[0]
340                .summaries
341                .get("formula.ppu")
342                .map(String::as_str),
343            Some("Average")
344        );
345    }
346
347    #[test]
348    fn tolerates_unknown_keys() {
349        let yaml = "unknownTop: 1\nviews:\n  - type: table\n    bogusField: x\n";
350        let base = parse_base(yaml).unwrap();
351        assert_eq!(base.views.len(), 1);
352    }
353
354    #[test]
355    fn parses_docgen_interactive_overrides() {
356        let yaml = r#"
357docgenInteractive: false
358views:
359  - type: table
360    order: [file.name, note.status]
361    docgenInteractive:
362      enabled: true
363      search: false
364      pageSize: 25
365      maxEnum: 10
366      filters:
367        note.status: text
368      sortable:
369        note.status: false
370      defaultSort:
371        - property: note.status
372          direction: DESC
373"#;
374        let base = parse_base(yaml).unwrap();
375        assert_eq!(base.docgen_interactive.map(|t| t.enabled()), Some(false));
376        let v = &base.views[0];
377        let iv = v.interactive.as_ref().unwrap();
378        assert_eq!(iv.enabled, Some(true));
379        assert_eq!(iv.search, Some(false));
380        assert_eq!(iv.page_size, Some(25));
381        assert_eq!(iv.max_enum, Some(10));
382        assert_eq!(
383            iv.filters.get("note.status").map(String::as_str),
384            Some("text")
385        );
386        assert_eq!(iv.sortable.get("note.status"), Some(&false));
387        assert_eq!(iv.default_sort.len(), 1);
388        assert_eq!(iv.default_sort[0].property(), "note.status");
389        assert!(iv.default_sort[0].descending());
390    }
391
392    #[test]
393    fn base_without_interactive_keys_still_parses() {
394        // Mirrors `tolerates_unknown_keys`: the new optional fields default to None.
395        let base = parse_base("views:\n  - type: table\n    order: [file.name]\n").unwrap();
396        assert!(base.docgen_interactive.is_none());
397        assert!(base.views[0].interactive.is_none());
398    }
399
400    #[test]
401    fn empty_base_is_valid() {
402        assert_eq!(parse_base("").unwrap().views.len(), 0);
403        assert_eq!(parse_base("   \n").unwrap().views.len(), 0);
404    }
405
406    #[test]
407    fn shorthand_sort_and_group() {
408        let yaml =
409            "views:\n  - type: table\n    sort:\n      - file.name\n    groupBy: note.status\n";
410        let base = parse_base(yaml).unwrap();
411        assert_eq!(base.views[0].sort[0].property(), "file.name");
412        assert!(!base.views[0].sort[0].descending());
413        assert_eq!(
414            base.views[0].group_by.as_ref().unwrap().property(),
415            "note.status"
416        );
417    }
418}