fix: canonicalize hidden system metadata
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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(':')?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
src-tauri/src/vault/system_metadata_tests.rs
Normal file
73
src-tauri/src/vault/system_metadata_tests.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user