1use std::collections::BTreeMap;
6
7use serde::Deserialize;
8
9#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(default)]
12pub struct BaseFile {
13 pub title: Option<String>,
17 pub filters: Option<Filter>,
19 pub formulas: BTreeMap<String, String>,
21 pub properties: BTreeMap<String, PropertyConfig>,
23 pub summaries: BTreeMap<String, String>,
25 pub views: Vec<View>,
27 #[serde(rename = "docgenInteractive")]
30 pub docgen_interactive: Option<InteractiveToggle>,
31}
32
33#[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#[derive(Debug, Clone, Default, Deserialize)]
47#[serde(default)]
48pub struct ViewInteractive {
49 pub enabled: Option<bool>,
51 pub search: Option<bool>,
53 #[serde(rename = "pageSize")]
55 pub page_size: Option<usize>,
56 #[serde(rename = "maxEnum")]
58 pub max_enum: Option<usize>,
59 pub filters: BTreeMap<String, String>,
61 pub sortable: BTreeMap<String, bool>,
63 #[serde(rename = "sortAs")]
71 pub sort_as: BTreeMap<String, String>,
72 #[serde(rename = "defaultSort")]
74 pub default_sort: Vec<SortKey>,
75 #[serde(flatten)]
81 pub unknown: BTreeMap<String, serde_yml::Value>,
82}
83
84impl ViewInteractive {
85 const KNOWN: &'static [&'static str] = &[
87 "enabled",
88 "search",
89 "pageSize",
90 "maxEnum",
91 "filters",
92 "sortable",
93 "sortAs",
94 "defaultSort",
95 ];
96
97 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#[derive(Debug, Clone, Default, Deserialize)]
128#[serde(default)]
129pub struct PropertyConfig {
130 #[serde(rename = "displayName")]
131 pub display_name: Option<String>,
132}
133
134#[derive(Debug, Clone, Deserialize)]
138#[serde(untagged)]
139pub enum Filter {
140 Expr(String),
142 Logic(LogicFilter),
145 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 pub not: Option<Box<NotFilter>>,
156}
157
158#[derive(Debug, Clone, Deserialize)]
160#[serde(untagged)]
161pub enum NotFilter {
162 One(Filter),
163 Many(Vec<Filter>),
164}
165
166#[derive(Debug, Clone, Default, Deserialize)]
168#[serde(default)]
169pub struct View {
170 #[serde(rename = "type")]
172 pub view_type: String,
173 pub name: Option<String>,
175 pub filters: Option<Filter>,
177 pub order: Vec<String>,
180 pub sort: Vec<SortKey>,
182 #[serde(rename = "groupBy")]
184 pub group_by: Option<GroupBy>,
185 pub limit: Option<usize>,
187 #[serde(rename = "columnSize")]
189 pub column_size: BTreeMap<String, u32>,
190 pub summaries: BTreeMap<String, String>,
192 pub image: Option<String>,
194 #[serde(rename = "docgenInteractive")]
196 pub interactive: Option<ViewInteractive>,
197}
198
199#[derive(Debug, Clone, Deserialize)]
201#[serde(untagged)]
202pub enum SortKey {
203 Property(String),
205 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 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#[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
264pub fn parse_base(yaml: &str) -> Result<BaseFile, serde_yml::Error> {
266 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 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}