fix: uniform StringOrList normalization for all frontmatter string fields

Replace per-field Option<String> with Option<StringOrList> 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) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-17 10:37:16 +01:00
parent fb825828f7
commit 6371e80f06
2 changed files with 173 additions and 25 deletions

View File

@@ -113,31 +113,31 @@ struct Frontmatter {
)]
trashed: Option<bool>,
#[serde(rename = "Status", alias = "status", default)]
status: Option<String>,
status: Option<StringOrList>,
#[serde(rename = "Owner", alias = "owner", default)]
owner: Option<String>,
owner: Option<StringOrList>,
#[serde(rename = "Cadence", alias = "cadence", default)]
cadence: Option<String>,
cadence: Option<StringOrList>,
#[serde(rename = "Trashed at", alias = "trashed_at")]
trashed_at: Option<String>,
trashed_at: Option<StringOrList>,
#[serde(rename = "Created at")]
created_at: Option<String>,
created_at: Option<StringOrList>,
#[serde(rename = "Created time")]
created_time: Option<String>,
created_time: Option<StringOrList>,
#[serde(default)]
icon: Option<String>,
icon: Option<StringOrList>,
#[serde(default)]
color: Option<String>,
color: Option<StringOrList>,
#[serde(default)]
order: Option<i64>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
sidebar_label: Option<StringOrList>,
#[serde(default)]
template: Option<String>,
template: Option<StringOrList>,
#[serde(default)]
sort: Option<String>,
sort: Option<StringOrList>,
#[serde(default)]
view: Option<String>,
view: Option<StringOrList>,
#[serde(default)]
visible: Option<bool>,
}
@@ -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<String> {
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<StringOrList>) -> Option<String> {
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
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<VaultEntry, String> {
.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<String>, 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

View File

@@ -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 })
})
})