From 6371e80f06d9dc3d361d6b345c451e2f4fd116a5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 17 Mar 2026 10:37:16 +0100 Subject: [PATCH] fix: uniform StringOrList normalization for all frontmatter string fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-field Option with Option for all string fields in the Frontmatter struct (owner, cadence, status, icon, color, sidebar_label, template, sort, view, trashed_at, created_at, created_time). Previously, fields like Owner stored as YAML arrays (e.g. `Owner: [Luca]`) caused serde to fail the entire Frontmatter deserialization, defaulting all fields to None — including is_a, which broke the type badge display. Now all string fields use StringOrList with into_scalar() normalization: - Single-element array → unwrap to scalar - Multi-element array → take first element - Scalar → unchanged - Empty array → None - Absent → None Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/mod.rs | 150 ++++++++++++++++---- tests/smoke/frontmatter-parsing-fix.spec.ts | 48 +++++++ 2 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 tests/smoke/frontmatter-parsing-fix.spec.ts diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index c1026baa..57aab1b5 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -113,31 +113,31 @@ struct Frontmatter { )] trashed: Option, #[serde(rename = "Status", alias = "status", default)] - status: Option, + status: Option, #[serde(rename = "Owner", alias = "owner", default)] - owner: Option, + owner: Option, #[serde(rename = "Cadence", alias = "cadence", default)] - cadence: Option, + cadence: Option, #[serde(rename = "Trashed at", alias = "trashed_at")] - trashed_at: Option, + trashed_at: Option, #[serde(rename = "Created at")] - created_at: Option, + created_at: Option, #[serde(rename = "Created time")] - created_time: Option, + created_time: Option, #[serde(default)] - icon: Option, + icon: Option, #[serde(default)] - color: Option, + color: Option, #[serde(default)] order: Option, #[serde(rename = "sidebar label", default)] - sidebar_label: Option, + sidebar_label: Option, #[serde(default)] - template: Option, + template: Option, #[serde(default)] - sort: Option, + sort: Option, #[serde(default)] - view: Option, + view: Option, #[serde(default)] visible: Option, } @@ -207,6 +207,21 @@ impl StringOrList { StringOrList::List(v) => v, } } + + /// Normalize to a single scalar: unwrap single-element arrays, take first + /// element of multi-element arrays, return scalar unchanged, empty array → None. + fn into_scalar(self) -> Option { + match self { + StringOrList::Single(s) => Some(s), + StringOrList::List(mut v) => { + if v.is_empty() { + None + } else { + Some(v.swap_remove(0)) + } + } + } + } } /// Parse frontmatter from raw YAML data extracted by gray_matter. @@ -355,9 +370,15 @@ fn resolve_is_a(fm_is_a: Option) -> Option { /// Parse created_at from frontmatter (prefer "Created at" over "Created time"). fn parse_created_at(fm: &Frontmatter) -> Option { fm.created_at - .as_ref() - .and_then(|s| parse_iso_date(s)) - .or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s))) + .clone() + .and_then(|v| v.into_scalar()) + .and_then(|s| parse_iso_date(&s)) + .or_else(|| { + fm.created_time + .clone() + .and_then(|v| v.into_scalar()) + .and_then(|s| parse_iso_date(&s)) + }) } /// Extract frontmatter, relationships, and custom properties from parsed gray_matter data. @@ -444,22 +465,26 @@ pub fn parse_md_file(path: &Path) -> Result { .unwrap_or_default(), belongs_to, related_to, - status: frontmatter.status, - owner: frontmatter.owner, - cadence: frontmatter.cadence, + status: frontmatter.status.and_then(|v| v.into_scalar()), + owner: frontmatter.owner.and_then(|v| v.into_scalar()), + cadence: frontmatter.cadence.and_then(|v| v.into_scalar()), archived: frontmatter.archived.unwrap_or(false), trashed: frontmatter.trashed.unwrap_or(false), - trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date), + trashed_at: frontmatter + .trashed_at + .and_then(|v| v.into_scalar()) + .as_deref() + .and_then(parse_iso_date), modified_at, created_at, file_size, - icon: frontmatter.icon, - color: frontmatter.color, + icon: frontmatter.icon.and_then(|v| v.into_scalar()), + color: frontmatter.color.and_then(|v| v.into_scalar()), order: frontmatter.order, - sidebar_label: frontmatter.sidebar_label, - template: frontmatter.template, - sort: frontmatter.sort, - view: frontmatter.view, + sidebar_label: frontmatter.sidebar_label.and_then(|v| v.into_scalar()), + template: frontmatter.template.and_then(|v| v.into_scalar()), + sort: frontmatter.sort.and_then(|v| v.into_scalar()), + view: frontmatter.view.and_then(|v| v.into_scalar()), visible: frontmatter.visible, word_count, outgoing_links, @@ -1657,6 +1682,81 @@ Company: Acme Corp assert_eq!(entry.is_a, Some("Quarter".to_string())); } + // --- StringOrList normalization (uniform, no per-field special cases) --- + + #[test] + fn test_single_element_array_owner_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Responsibility\nOwner:\n - Luca\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.is_a, Some("Responsibility".to_string())); + } + + #[test] + fn test_single_element_array_cadence_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Procedure\nCadence:\n - Weekly\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.cadence, Some("Weekly".to_string())); + assert_eq!(entry.is_a, Some("Procedure".to_string())); + } + + #[test] + fn test_single_element_array_status_unwraps_to_scalar() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\nStatus:\n - Active\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.status, Some("Active".to_string())); + assert_eq!(entry.is_a, Some("Project".to_string())); + } + + #[test] + fn test_multi_element_array_owner_takes_first() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\nOwner:\n - Alice\n - Bob\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Alice".to_string())); + } + + #[test] + fn test_scalar_fields_unchanged() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.cadence, Some("Daily".to_string())); + assert_eq!(entry.status, Some("Done".to_string())); + } + + #[test] + fn test_absent_fields_no_crash() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!(entry.owner, None); + assert_eq!(entry.cadence, None); + assert_eq!(entry.status, None); + } + + #[test] + fn test_array_field_does_not_break_type_detection() { + // Regression: when Owner was Option, a YAML array like [Luca] + // caused serde to fail the entire Frontmatter → all fields defaulted to None, + // losing the is_a field and breaking the type badge. + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n"; + let entry = parse_test_entry(&dir, "test.md", content); + assert_eq!( + entry.is_a, + Some("Responsibility".to_string()), + "type must not be lost when other fields are arrays" + ); + assert_eq!(entry.owner, Some("Luca".to_string())); + assert_eq!(entry.cadence, Some("Weekly".to_string())); + assert_eq!(entry.status, Some("Active".to_string())); + } + // Frontmatter update/delete tests are in frontmatter.rs // save_image tests are in vault/image.rs // purge_trash tests are in vault/trash.rs diff --git a/tests/smoke/frontmatter-parsing-fix.spec.ts b/tests/smoke/frontmatter-parsing-fix.spec.ts new file mode 100644 index 00000000..ffe21670 --- /dev/null +++ b/tests/smoke/frontmatter-parsing-fix.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]' + +async function openQuickOpen(page: import('@playwright/test').Page) { + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible() +} + +test.describe('Frontmatter parsing: type badge displays correctly', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('procedure note shows type badge in Quick Open', async ({ page }) => { + await openQuickOpen(page) + await page.locator(QUICK_OPEN_INPUT).fill('Weekly') + await page.waitForTimeout(400) + // The Badge component renders the type name as text content + const badge = page.locator('.fixed.inset-0').locator('text=Procedure') + await expect(badge.first()).toBeVisible({ timeout: 3000 }) + }) + + test('responsibility note shows type badge in Quick Open', async ({ page }) => { + await openQuickOpen(page) + await page.locator(QUICK_OPEN_INPUT).fill('Newsletter') + await page.waitForTimeout(400) + const badge = page.locator('.fixed.inset-0').locator('text=Responsibility') + await expect(badge.first()).toBeVisible({ timeout: 3000 }) + }) + + test('project note shows type badge in Quick Open', async ({ page }) => { + await openQuickOpen(page) + await page.locator(QUICK_OPEN_INPUT).fill('Laputa') + await page.waitForTimeout(400) + const badge = page.locator('.fixed.inset-0').locator('text=Project') + await expect(badge.first()).toBeVisible({ timeout: 3000 }) + }) + + test('sidebar shows type sections', async ({ page }) => { + // Sidebar sections are rendered as nav items — look for them in the page + await expect(page.locator('text=Projects').first()).toBeVisible({ timeout: 3000 }) + await expect(page.locator('text=Responsibilities').first()).toBeVisible({ timeout: 3000 }) + }) +})