From 474353718ad9d693a75d12fd18604d710325dbc5 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 9 Mar 2026 11:59:27 +0100 Subject: [PATCH] fix: handle Archived/Trashed Yes/No string values in frontmatter parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust YAML parser only accepted boolean values (true/false) for the archived and trashed fields. When the vault writes Archived: Yes or Trashed: Yes (YAML string, not boolean), serde silently returned None and the note appeared as non-archived/non-trashed. Add a custom deserializer that accepts both booleans and string representations (Yes/yes/YES/true/1 → true, No/no/false/0 → false). Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/vault/mod.rs | 149 ++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 68afaad7..f78ae7ad 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -108,9 +108,19 @@ struct Frontmatter { owner: Option, #[serde(rename = "Cadence")] cadence: Option, - #[serde(rename = "Archived", alias = "archived")] + #[serde( + rename = "Archived", + alias = "archived", + default, + deserialize_with = "deserialize_bool_or_string" + )] archived: Option, - #[serde(rename = "Trashed", alias = "trashed")] + #[serde( + rename = "Trashed", + alias = "trashed", + default, + deserialize_with = "deserialize_bool_or_string" + )] trashed: Option, #[serde(rename = "Trashed at", alias = "trashed_at")] trashed_at: Option, @@ -136,6 +146,56 @@ struct Frontmatter { visible: Option, } +/// Custom deserializer for boolean fields that may arrive as strings. +/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we +/// need to accept both actual booleans and their string representations. +fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct BoolOrStringVisitor; + + impl<'de> de::Visitor<'de> for BoolOrStringVisitor { + type Value = Option; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a boolean or a string representing a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Some(v)) + } + + fn visit_str(self, v: &str) -> Result { + match v.to_lowercase().as_str() { + "true" | "yes" | "1" => Ok(Some(true)), + "false" | "no" | "0" | "" => Ok(Some(false)), + _ => Ok(Some(false)), + } + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Some(v != 0)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Some(v != 0)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(BoolOrStringVisitor) +} + /// Handles YAML fields that can be either a single string or a list of strings. #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] @@ -1440,6 +1500,91 @@ Company: Acme Corp assert!(entry.trashed_at.is_none()); } + // --- archived/trashed string-value tests --- + + #[test] + fn test_parse_archived_yes_titlecase() { + let dir = TempDir::new().unwrap(); + let content = "---\nArchived: Yes\n---\n# Old\n"; + let entry = parse_test_entry(&dir, "old.md", content); + assert!(entry.archived, "'Archived: Yes' must be parsed as true"); + } + + #[test] + fn test_parse_archived_yes_lowercase() { + let dir = TempDir::new().unwrap(); + let content = "---\narchived: yes\n---\n# Old\n"; + let entry = parse_test_entry(&dir, "old2.md", content); + assert!(entry.archived, "'archived: yes' must be parsed as true"); + } + + #[test] + fn test_parse_archived_yes_uppercase() { + let dir = TempDir::new().unwrap(); + let content = "---\nArchived: YES\n---\n# Old\n"; + let entry = parse_test_entry(&dir, "old3.md", content); + assert!(entry.archived, "'Archived: YES' must be parsed as true"); + } + + #[test] + fn test_parse_archived_no() { + let dir = TempDir::new().unwrap(); + let content = "---\nArchived: No\n---\n# Active\n"; + let entry = parse_test_entry(&dir, "active2.md", content); + assert!(!entry.archived, "'Archived: No' must be parsed as false"); + } + + #[test] + fn test_parse_archived_false_string() { + let dir = TempDir::new().unwrap(); + let content = "---\nArchived: \"false\"\n---\n# Active\n"; + let entry = parse_test_entry(&dir, "active3.md", content); + assert!( + !entry.archived, + "'Archived: \"false\"' must be parsed as false" + ); + } + + #[test] + fn test_parse_archived_zero() { + let dir = TempDir::new().unwrap(); + let content = "---\nArchived: 0\n---\n# Active\n"; + let entry = parse_test_entry(&dir, "active4.md", content); + assert!(!entry.archived, "'Archived: 0' must be parsed as false"); + } + + #[test] + fn test_parse_archived_absent() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Active\n"; + let entry = parse_test_entry(&dir, "active5.md", content); + assert!(!entry.archived, "absent archived must default to false"); + } + + #[test] + fn test_parse_trashed_yes_titlecase() { + let dir = TempDir::new().unwrap(); + let content = "---\nTrashed: Yes\n---\n# Gone\n"; + let entry = parse_test_entry(&dir, "gone2.md", content); + assert!(entry.trashed, "'Trashed: Yes' must be parsed as true"); + } + + #[test] + fn test_parse_trashed_yes_lowercase() { + let dir = TempDir::new().unwrap(); + let content = "---\ntrashed: yes\n---\n# Gone\n"; + let entry = parse_test_entry(&dir, "gone3.md", content); + assert!(entry.trashed, "'trashed: yes' must be parsed as true"); + } + + #[test] + fn test_parse_trashed_no() { + let dir = TempDir::new().unwrap(); + let content = "---\nTrashed: No\n---\n# Active\n"; + let entry = parse_test_entry(&dir, "active6.md", content); + assert!(!entry.trashed, "'Trashed: No' must be parsed as false"); + } + // --- visible field tests --- #[test]