use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct BaseFile {
pub filters: Option<Filter>,
pub formulas: BTreeMap<String, String>,
pub properties: BTreeMap<String, PropertyConfig>,
pub summaries: BTreeMap<String, String>,
pub views: Vec<View>,
#[serde(rename = "docgenInteractive")]
pub docgen_interactive: Option<InteractiveToggle>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(transparent)]
pub struct InteractiveToggle(pub bool);
impl InteractiveToggle {
pub fn enabled(&self) -> bool {
self.0
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ViewInteractive {
pub enabled: Option<bool>,
pub search: Option<bool>,
#[serde(rename = "pageSize")]
pub page_size: Option<usize>,
#[serde(rename = "maxEnum")]
pub max_enum: Option<usize>,
pub filters: BTreeMap<String, String>,
pub sortable: BTreeMap<String, bool>,
#[serde(rename = "sortAs")]
pub sort_as: BTreeMap<String, String>,
#[serde(rename = "defaultSort")]
pub default_sort: Vec<SortKey>,
#[serde(flatten)]
pub unknown: BTreeMap<String, serde_yml::Value>,
}
impl ViewInteractive {
const KNOWN: &'static [&'static str] = &[
"enabled",
"search",
"pageSize",
"maxEnum",
"filters",
"sortable",
"sortAs",
"defaultSort",
];
pub fn unknown_key_warning(&self) -> Option<String> {
if self.unknown.is_empty() {
return None;
}
let parts: Vec<String> = self
.unknown
.keys()
.map(|k| {
match Self::KNOWN
.iter()
.find(|known| known.eq_ignore_ascii_case(k))
{
Some(known) => format!("`{k}` (did you mean `{known}`?)"),
None => format!("`{k}`"),
}
})
.collect();
Some(format!(
"unknown docgenInteractive key: {}",
parts.join(", ")
))
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PropertyConfig {
#[serde(rename = "displayName")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Filter {
Expr(String),
Logic(LogicFilter),
List(Vec<Filter>),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct LogicFilter {
pub and: Option<Vec<Filter>>,
pub or: Option<Vec<Filter>>,
pub not: Option<Box<NotFilter>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum NotFilter {
One(Filter),
Many(Vec<Filter>),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct View {
#[serde(rename = "type")]
pub view_type: String,
pub name: Option<String>,
pub filters: Option<Filter>,
pub order: Vec<String>,
pub sort: Vec<SortKey>,
#[serde(rename = "groupBy")]
pub group_by: Option<GroupBy>,
pub limit: Option<usize>,
#[serde(rename = "columnSize")]
pub column_size: BTreeMap<String, u32>,
pub summaries: BTreeMap<String, String>,
pub image: Option<String>,
#[serde(rename = "docgenInteractive")]
pub interactive: Option<ViewInteractive>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum SortKey {
Property(String),
Full {
property: String,
#[serde(default)]
direction: Option<String>,
},
}
impl SortKey {
pub fn property(&self) -> &str {
match self {
SortKey::Property(p) => p,
SortKey::Full { property, .. } => property,
}
}
pub fn descending(&self) -> bool {
match self {
SortKey::Property(_) => false,
SortKey::Full { direction, .. } => direction
.as_deref()
.map(|d| d.eq_ignore_ascii_case("desc"))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum GroupBy {
Property(String),
Full {
property: String,
#[serde(default)]
direction: Option<String>,
},
}
impl GroupBy {
pub fn property(&self) -> &str {
match self {
GroupBy::Property(p) => p,
GroupBy::Full { property, .. } => property,
}
}
pub fn descending(&self) -> bool {
match self {
GroupBy::Property(_) => false,
GroupBy::Full { direction, .. } => direction
.as_deref()
.map(|d| d.eq_ignore_ascii_case("desc"))
.unwrap_or(false),
}
}
}
pub fn parse_base(yaml: &str) -> Result<BaseFile, serde_yml::Error> {
if yaml.trim().is_empty() {
return Ok(BaseFile::default());
}
serde_yml::from_str(yaml)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_real_vault_shape() {
let yaml = r#"
filters:
and:
- categories.contains(link("Categories/Books", "Books"))
- '!file.inFolder("Misc")'
views:
- type: table
name: Table
order:
- file.name
- file.size
sort:
- property: file.name
direction: DESC
columnSize:
file.name: 381
"#;
let base = parse_base(yaml).unwrap();
assert!(base.filters.is_some());
assert_eq!(base.views.len(), 1);
let v = &base.views[0];
assert_eq!(v.view_type, "table");
assert_eq!(v.name.as_deref(), Some("Table"));
assert_eq!(v.order, vec!["file.name", "file.size"]);
assert_eq!(v.sort.len(), 1);
assert_eq!(v.sort[0].property(), "file.name");
assert!(v.sort[0].descending());
assert_eq!(v.column_size.get("file.name"), Some(&381));
}
#[test]
fn parses_formulas_and_summaries() {
let yaml = r#"
formulas:
formatted_price: 'if(price, price.toFixed(2) + " dollars")'
ppu: "(price / age).toFixed(2)"
summaries:
customAverage: 'values.mean().round(3)'
properties:
status:
displayName: Status
views:
- type: table
summaries:
formula.ppu: Average
"#;
let base = parse_base(yaml).unwrap();
assert_eq!(base.formulas.len(), 2);
assert!(base.formulas.contains_key("ppu"));
assert_eq!(
base.summaries.get("customAverage").map(String::as_str),
Some("values.mean().round(3)")
);
assert_eq!(
base.properties
.get("status")
.and_then(|p| p.display_name.as_deref()),
Some("Status")
);
assert_eq!(
base.views[0]
.summaries
.get("formula.ppu")
.map(String::as_str),
Some("Average")
);
}
#[test]
fn tolerates_unknown_keys() {
let yaml = "unknownTop: 1\nviews:\n - type: table\n bogusField: x\n";
let base = parse_base(yaml).unwrap();
assert_eq!(base.views.len(), 1);
}
#[test]
fn parses_docgen_interactive_overrides() {
let yaml = r#"
docgenInteractive: false
views:
- type: table
order: [file.name, note.status]
docgenInteractive:
enabled: true
search: false
pageSize: 25
maxEnum: 10
filters:
note.status: text
sortable:
note.status: false
defaultSort:
- property: note.status
direction: DESC
"#;
let base = parse_base(yaml).unwrap();
assert_eq!(base.docgen_interactive.map(|t| t.enabled()), Some(false));
let v = &base.views[0];
let iv = v.interactive.as_ref().unwrap();
assert_eq!(iv.enabled, Some(true));
assert_eq!(iv.search, Some(false));
assert_eq!(iv.page_size, Some(25));
assert_eq!(iv.max_enum, Some(10));
assert_eq!(
iv.filters.get("note.status").map(String::as_str),
Some("text")
);
assert_eq!(iv.sortable.get("note.status"), Some(&false));
assert_eq!(iv.default_sort.len(), 1);
assert_eq!(iv.default_sort[0].property(), "note.status");
assert!(iv.default_sort[0].descending());
}
#[test]
fn base_without_interactive_keys_still_parses() {
let base = parse_base("views:\n - type: table\n order: [file.name]\n").unwrap();
assert!(base.docgen_interactive.is_none());
assert!(base.views[0].interactive.is_none());
}
#[test]
fn empty_base_is_valid() {
assert_eq!(parse_base("").unwrap().views.len(), 0);
assert_eq!(parse_base(" \n").unwrap().views.len(), 0);
}
#[test]
fn shorthand_sort_and_group() {
let yaml =
"views:\n - type: table\n sort:\n - file.name\n groupBy: note.status\n";
let base = parse_base(yaml).unwrap();
assert_eq!(base.views[0].sort[0].property(), "file.name");
assert!(!base.views[0].sort[0].descending());
assert_eq!(
base.views[0].group_by.as_ref().unwrap().property(),
"note.status"
);
}
}