fix: canonicalize hidden system metadata

This commit is contained in:
lucaronin
2026-04-16 06:21:34 +02:00
parent e8e2004aa8
commit e9c1ae2b8a
11 changed files with 700 additions and 750 deletions

View File

@@ -27,6 +27,30 @@ fn is_value_continuation(line: &str) -> bool {
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
}
fn normalize_system_key(key: &str) -> String {
key.trim().to_ascii_lowercase().replace(' ', "_")
}
fn canonical_system_key(key: &str) -> Option<&'static str> {
match normalize_system_key(key).as_str() {
"_icon" | "icon" => Some("_icon"),
"_order" | "order" => Some("_order"),
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some("_sidebar_label"),
"_sort" | "sort" => Some("_sort"),
_ => None,
}
}
fn legacy_system_key_aliases(canonical: &str) -> &'static [&'static str] {
match canonical {
"_icon" => &["icon"],
"_order" => &["order"],
"_sidebar_label" => &["sidebar_label", "sidebar label"],
"_sort" => &["sort"],
_ => &[],
}
}
/// Split content into frontmatter body and the rest after the closing `---`.
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
@@ -82,11 +106,10 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
new_lines
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
fn update_frontmatter_content_raw(
content: &str,
key: &str,
value: Option<FrontmatterValue>,
value: Option<&FrontmatterValue>,
) -> Result<String, String> {
if !content.starts_with("---\n") {
return match value {
@@ -97,52 +120,90 @@ pub fn update_frontmatter_content(
let (fm_content, rest) = split_frontmatter(content)?;
let lines: Vec<&str> = fm_content.lines().collect();
let new_lines = apply_field_update(&lines, key, value.as_ref());
let new_lines = apply_field_update(&lines, key, value);
let new_fm = new_lines.join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
key: &str,
value: Option<FrontmatterValue>,
) -> Result<String, String> {
let Some(canonical) = canonical_system_key(key) else {
return update_frontmatter_content_raw(content, key, value.as_ref());
};
let mut updated = content.to_string();
for alias in legacy_system_key_aliases(canonical) {
updated = update_frontmatter_content_raw(&updated, alias, None)?;
}
update_frontmatter_content_raw(&updated, canonical, value.as_ref())
}
#[cfg(test)]
mod tests {
use super::*;
struct UpdateExpectation<'a> {
content: &'a str,
key: &'a str,
value: Option<FrontmatterValue>,
expected_present: &'a [&'a str],
expected_absent: &'a [&'a str],
}
fn assert_updated_content(expectation: UpdateExpectation<'_>) {
let updated =
update_frontmatter_content(expectation.content, expectation.key, expectation.value)
.unwrap();
for expected in expectation.expected_present {
assert!(
updated.contains(expected),
"missing expected snippet: {expected}"
);
}
for unexpected in expectation.expected_absent {
assert!(
!updated.contains(unexpected),
"found unexpected snippet: {unexpected}"
);
}
}
#[test]
fn test_update_frontmatter_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
assert!(updated.contains("Status: Active"));
assert!(!updated.contains("Status: Draft"));
assert_updated_content(UpdateExpectation {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Status",
value: Some(FrontmatterValue::String("Active".to_string())),
expected_present: &["Status: Active"],
expected_absent: &["Status: Draft"],
});
}
#[test]
fn test_update_frontmatter_add_new_key() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(updated.contains("Owner: Luca"));
assert!(updated.contains("Status: Draft"));
assert_updated_content(UpdateExpectation {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Owner",
value: Some(FrontmatterValue::String("Luca".to_string())),
expected_present: &["Owner: Luca", "Status: Draft"],
expected_absent: &[],
});
}
#[test]
fn test_update_frontmatter_quoted_key() {
let content = "---\n\"Is A\": Note\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Is A",
Some(FrontmatterValue::String("Project".to_string())),
)
.unwrap();
assert!(updated.contains("\"Is A\": Project"));
assert!(!updated.contains("Note"));
assert_updated_content(UpdateExpectation {
content: "---\n\"Is A\": Note\n---\n# Test\n",
key: "Is A",
value: Some(FrontmatterValue::String("Project".to_string())),
expected_present: &["\"Is A\": Project"],
expected_absent: &["\"Is A\": Note"],
});
}
#[test]
@@ -196,9 +257,8 @@ mod tests {
#[test]
fn test_update_frontmatter_no_existing() {
let content = "# Test\n\nSome content here.";
let updated = update_frontmatter_content(
content,
"# Test\n\nSome content here.",
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
@@ -278,6 +338,39 @@ mod tests {
assert_eq!(updated, content);
}
#[test]
fn test_update_frontmatter_rewrites_legacy_icon_key() {
assert_updated_content(UpdateExpectation {
content: "---\nicon: rocket\n---\n# Test\n",
key: "icon",
value: Some(FrontmatterValue::String("star".to_string())),
expected_present: &["_icon: star"],
expected_absent: &["\nicon:", "rocket"],
});
}
#[test]
fn test_update_frontmatter_rewrites_sidebar_label_aliases() {
assert_updated_content(UpdateExpectation {
content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n",
key: "_sidebar_label",
value: Some(FrontmatterValue::String("Programs".to_string())),
expected_present: &["_sidebar_label: Programs"],
expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"],
});
}
#[test]
fn test_delete_frontmatter_removes_sort_aliases() {
assert_updated_content(UpdateExpectation {
content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n",
key: "_sort",
value: None,
expected_present: &["# Test"],
expected_absent: &["\nsort:", "\n_sort:"],
});
}
#[test]
fn test_line_is_key_unquoted() {
assert!(line_is_key("Status: Draft", "Status"));

View File

@@ -21,17 +21,22 @@ pub(crate) struct Frontmatter {
pub archived: Option<bool>,
#[serde(rename = "Status", alias = "status", default)]
pub status: Option<StringOrList>,
#[serde(default)]
#[serde(rename = "_icon", alias = "icon", default)]
pub icon: Option<StringOrList>,
#[serde(default)]
pub color: Option<StringOrList>,
#[serde(default)]
#[serde(rename = "_order", alias = "order", default)]
pub order: Option<i64>,
#[serde(rename = "sidebar label", default)]
#[serde(
rename = "_sidebar_label",
alias = "sidebar label",
alias = "sidebar_label",
default
)]
pub sidebar_label: Option<StringOrList>,
#[serde(default)]
pub template: Option<StringOrList>,
#[serde(default)]
#[serde(rename = "_sort", alias = "sort", default)]
pub sort: Option<StringOrList>,
#[serde(default)]
pub view: Option<StringOrList>,
@@ -147,28 +152,28 @@ impl StringOrList {
///
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
/// preventing serde deserialization of the entire Frontmatter struct from failing.
fn sanitize_array_item(item: &serde_json::Value) -> Option<serde_json::Value> {
match item {
serde_json::Value::Null => None,
serde_json::Value::Object(map) if !map.is_empty() => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => format!("{}: {}", k, s),
_ => format!("{}: {}", k, v),
})
.collect();
Some(serde_json::Value::String(parts.join(", ")))
}
other => Some(other.clone()),
}
}
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Array(arr) => {
let sanitized: Vec<serde_json::Value> = arr
.iter()
.filter_map(|item| match item {
// Drop nulls (from `# comment` in list items)
serde_json::Value::Null => None,
// Convert mis-parsed objects back to "key: value" strings
serde_json::Value::Object(map) if !map.is_empty() => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| match v {
serde_json::Value::String(s) => format!("{}: {}", k, s),
_ => format!("{}: {}", k, v),
})
.collect();
Some(serde_json::Value::String(parts.join(", ")))
}
other => Some(other.clone()),
})
.collect();
let sanitized: Vec<serde_json::Value> =
arr.iter().filter_map(sanitize_array_item).collect();
serde_json::Value::Array(sanitized)
}
other => other.clone(),
@@ -186,11 +191,16 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
"_archived",
"Archived",
"archived",
"_icon",
"icon",
"color",
"_order",
"order",
"_sidebar_label",
"sidebar_label",
"sidebar label",
"template",
"_sort",
"sort",
"view",
"visible",
@@ -216,7 +226,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Note: owner and cadence are NOT skipped — they should appear in generic properties.
const SKIP_KEYS: &[&str] = &[
"title",
"is a",
"is_a",
"type",
"aliases",
"_archived",
@@ -224,7 +234,7 @@ const SKIP_KEYS: &[&str] = &[
"icon",
"color",
"order",
"sidebar label",
"sidebar_label",
"template",
"sort",
"view",
@@ -236,6 +246,15 @@ const SKIP_KEYS: &[&str] = &[
"_list_properties_display",
];
fn normalize_frontmatter_key(key: &str) -> String {
key.trim().to_ascii_lowercase().replace(' ', "_")
}
fn should_skip_frontmatter_key(key: &str) -> bool {
let normalized = normalize_frontmatter_key(key);
normalized.starts_with('_') || SKIP_KEYS.contains(&normalized.as_str())
}
/// Extract all wikilink-containing fields from raw YAML frontmatter.
pub(crate) fn extract_relationships(
data: &HashMap<String, serde_json::Value>,
@@ -243,7 +262,7 @@ pub(crate) fn extract_relationships(
let mut relationships = HashMap::new();
for (key, value) in data {
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
if should_skip_frontmatter_key(key) {
continue;
}
@@ -278,8 +297,7 @@ pub(crate) fn extract_properties(
let mut properties = HashMap::new();
for (key, value) in data {
let lower = key.to_ascii_lowercase();
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) {
if should_skip_frontmatter_key(key) {
continue;
}
@@ -360,8 +378,12 @@ fn parse_scalar(s: &str) -> serde_json::Value {
/// Return the key from a top-level `key:` or `"key":` YAML line.
/// Returns `None` for indented, blank, or non-key lines.
fn is_top_level_yaml_line(line: &str) -> bool {
!line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t')
}
fn extract_yaml_key(line: &str) -> Option<&str> {
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
if !is_top_level_yaml_line(line) {
return None;
}
let (k, _) = line.split_once(':')?;

View File

@@ -454,5 +454,8 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
#[path = "relationship_key_tests.rs"]
mod relationship_key_tests;
#[cfg(test)]
#[path = "system_metadata_tests.rs"]
mod system_metadata_tests;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;

View File

@@ -0,0 +1,73 @@
use super::*;
use std::fs;
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
let file_path = dir.join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
create_test_file(dir.path(), name, content);
parse_md_file(&dir.path().join(name), None).unwrap()
}
#[test]
fn parses_canonical_system_metadata_keys() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project.md",
"---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n",
);
assert_eq!(entry.icon.as_deref(), Some("rocket"));
assert_eq!(entry.order, Some(4));
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
}
#[test]
fn parses_legacy_system_metadata_keys_without_property_leaks() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"project.md",
"---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n",
);
assert_eq!(entry.icon.as_deref(), Some("rocket"));
assert_eq!(entry.order, Some(4));
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
assert!(!entry.properties.contains_key("icon"));
assert!(!entry.properties.contains_key("order"));
assert!(!entry.properties.contains_key("sidebar label"));
assert!(!entry.properties.contains_key("sort"));
}
#[test]
fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"note.md",
"---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n",
);
assert!(!entry.properties.contains_key("_internal"));
assert!(!entry.relationships.contains_key("_hidden_link"));
assert_eq!(
entry
.properties
.get("Owner")
.and_then(|value| value.as_str()),
Some("Luca")
);
}