From 6e41328f210514c22e5ea00217a082cf5819356f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 1 May 2026 18:09:48 +0200 Subject: [PATCH] fix: tolerate colliding frontmatter properties --- lara.lock | 5 +- src-tauri/src/vault/frontmatter.rs | 127 ++++++++++-------- .../src/vault/frontmatter_regression_tests.rs | 10 ++ src/components/CreateViewDialog.test.tsx | 4 +- src/components/Inspector.test.tsx | 20 +++ src/components/Inspector.tsx | 15 ++- src/components/LinuxMenuButton.test.tsx | 4 +- src/components/inspector/InspectorChrome.tsx | 38 +++++- src/lib/locales/de-DE.json | 4 +- src/lib/locales/en.json | 2 + src/lib/locales/es-419.json | 4 +- src/lib/locales/es-ES.json | 4 +- src/lib/locales/fr-FR.json | 4 +- src/lib/locales/it-IT.json | 4 +- src/lib/locales/ja-JP.json | 4 +- src/lib/locales/ko-KR.json | 4 +- src/lib/locales/pl-PL.json | 4 +- src/lib/locales/pt-BR.json | 4 +- src/lib/locales/pt-PT.json | 4 +- src/lib/locales/ru-RU.json | 4 +- src/lib/locales/vi.json | 2 + src/lib/locales/zh-CN.json | 4 +- src/lib/locales/zh-TW.json | 4 +- src/utils/frontmatter.test.ts | 19 ++- src/utils/frontmatter.ts | 80 ++++++++++- 25 files changed, 294 insertions(+), 84 deletions(-) diff --git a/lara.lock b/lara.lock index d581202a..52102f00 100644 --- a/lara.lock +++ b/lara.lock @@ -11,7 +11,7 @@ files: command.openSettings: 638c05f4f159a403e04aebe94b46a2a8 command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7 command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183 - command.openLanguageSettings.keywords: 639afefdd1c238b381bc1f963288f389 + command.openLanguageSettings.keywords: 6a910000cbf23eac3c7078e518cae66a command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 @@ -385,6 +385,8 @@ files: inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9 inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407 inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29 + inspector.title.collidingProperties: bf71358d0af34696e88bea278eac046a + inspector.title.collidingPropertiesAria: ce5c3569f304cd69b0f33a6fc185f49b inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629 inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822 inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4 @@ -525,3 +527,4 @@ files: locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e locale.koKR: d0bdb3cde477d82e766da05ebda50ccb locale.vi: 7b80fae85640c16cdb0261bef0c27636 + locale.plPL: c730389bc8d99e59c867766babdd48b5 diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 00648b31..d9b81d1a 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -182,69 +182,78 @@ fn sanitize_value(value: &serde_json::Value) -> serde_json::Value { } } -fn type_key_priority(key: &str) -> u8 { - match key { - "type" => 0, - "Type" => 1, - "TYPE" => 2, - _ => 3, +fn canonical_known_key(key: &str) -> Option<&'static str> { + let trimmed = key.trim(); + if trimmed.eq_ignore_ascii_case("type") { + return Some("type"); + } + match trimmed { + "title" => Some("title"), + "Is A" | "is_a" => Some("type"), + "aliases" => Some("aliases"), + "_archived" | "Archived" | "archived" => Some("_archived"), + "_icon" | "icon" => Some("_icon"), + "color" => Some("color"), + "_order" | "order" => Some("_order"), + "_sidebar_label" | "sidebar_label" | "sidebar label" => Some("_sidebar_label"), + "template" => Some("template"), + "_sort" | "sort" => Some("_sort"), + "view" => Some("view"), + "_width" | "width" => Some("_width"), + "visible" => Some("visible"), + "Status" | "status" => Some("Status"), + "_organized" => Some("_organized"), + "_favorite" => Some("_favorite"), + "_favorite_index" => Some("_favorite_index"), + "_list_properties_display" => Some("_list_properties_display"), + _ => None, } } -fn find_type_value(data: &HashMap) -> Option<&serde_json::Value> { - data.iter() - .filter(|(key, _)| key.eq_ignore_ascii_case("type")) - .min_by_key(|(key, _)| type_key_priority(key)) - .map(|(_, value)| value) +fn insert_known_frontmatter_value( + target: &mut serde_json::Map, + key: &str, + value: &serde_json::Value, + overwrite: bool, +) { + let Some(canonical_key) = canonical_known_key(key) else { + return; + }; + if overwrite || !target.contains_key(canonical_key) { + target.insert(canonical_key.to_string(), sanitize_value(value)); + } +} + +fn raw_frontmatter_keys(raw_content: &str) -> Vec { + RawFrontmatter(raw_content) + .extract_block() + .map(|raw| { + raw.lines() + .filter_map(|line| YamlLine(line).key().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn known_frontmatter_map( + data: &HashMap, + raw_content: &str, +) -> serde_json::Map { + let mut filtered = serde_json::Map::new(); + for key in raw_frontmatter_keys(raw_content) { + if let Some(value) = data.get(&key) { + insert_known_frontmatter_value(&mut filtered, &key, value, true); + } + } + for (key, value) in data { + insert_known_frontmatter_value(&mut filtered, key, value, false); + } + filtered } /// Parse frontmatter from raw YAML data extracted by gray_matter. -fn parse_frontmatter(data: &HashMap) -> Frontmatter { - static KNOWN_KEYS: &[&str] = &[ - "title", - "type", - "Is A", - "is_a", - "aliases", - "_archived", - "Archived", - "archived", - "_icon", - "icon", - "color", - "_order", - "order", - "_sidebar_label", - "sidebar_label", - "sidebar label", - "template", - "_sort", - "sort", - "view", - "_width", - "width", - "visible", - "notion_id", - "Status", - "status", - "_organized", - "_favorite", - "_favorite_index", - "_list_properties_display", - ]; - let mut filtered = serde_json::Map::new(); - - if let Some(value) = find_type_value(data) { - filtered.insert("type".to_string(), sanitize_value(value)); - } - - for (key, value) in data { - if key.eq_ignore_ascii_case("type") || !KNOWN_KEYS.contains(&key.as_str()) { - continue; - } - filtered.insert(key.clone(), sanitize_value(value)); - } - +fn parse_frontmatter(data: &HashMap, raw_content: &str) -> Frontmatter { + let filtered = known_frontmatter_map(data, raw_content); let value = serde_json::Value::Object(filtered); serde_json::from_value(value).unwrap_or_default() } @@ -443,7 +452,7 @@ impl<'a> YamlLine<'a> { return None; } let (key, _) = self.0.split_once(':')?; - Some(key.trim().trim_matches('"')) + Some(key.trim().trim_matches('"').trim_matches('\'')) } fn list_item(self) -> Option<&'a str> { @@ -543,7 +552,7 @@ pub(crate) fn extract_fm_and_rels( } }; ( - parse_frontmatter(&json_map), + parse_frontmatter(&json_map, raw_content), extract_relationships(&json_map), extract_properties(&json_map), ) diff --git a/src-tauri/src/vault/frontmatter_regression_tests.rs b/src-tauri/src/vault/frontmatter_regression_tests.rs index 3657e1ab..dd760591 100644 --- a/src-tauri/src/vault/frontmatter_regression_tests.rs +++ b/src-tauri/src/vault/frontmatter_regression_tests.rs @@ -133,3 +133,13 @@ fn test_alias_parser_recovers_special_alias_items() { assert_alias_parser_recovers(case); } } + +#[test] +fn test_alias_collisions_keep_frontmatter_with_last_value_winning() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n"; + let entry = parse_test_entry(&dir, "test.md", content); + + assert_eq!(entry.is_a, Some("Note".to_string())); + assert_eq!(entry.status, Some("Evergreened".to_string())); +} diff --git a/src/components/CreateViewDialog.test.tsx b/src/components/CreateViewDialog.test.tsx index 57f03a2a..31f71f8f 100644 --- a/src/components/CreateViewDialog.test.tsx +++ b/src/components/CreateViewDialog.test.tsx @@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest' import { CreateViewDialog } from './CreateViewDialog' import type { ViewDefinition } from '../types' +const DIALOG_TEST_TIMEOUT_MS = 10_000 + describe('CreateViewDialog', () => { const defaultProps = { open: true, @@ -26,7 +28,7 @@ describe('CreateViewDialog', () => { render() expect(screen.getByText('Create View')).toBeInTheDocument() expect(screen.getByText('Create')).toBeInTheDocument() - }) + }, DIALOG_TEST_TIMEOUT_MS) it('shows "Edit View" title when editingView is provided', () => { render() diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index ddbc76df..d71c3fb6 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -129,6 +129,26 @@ describe('Inspector', () => { expect(onToggle).toHaveBeenCalledOnce() }) + it('shows a colliding-properties warning that opens the raw editor', async () => { + const onToggleRawEditor = vi.fn() + const content = `--- +type: Note +status: Active +Status: Evergreened +--- +# Test Project +` + + renderSelectedInspector({ content, onToggleRawEditor }) + + const warning = screen.getByRole('button', { name: 'Colliding properties. Open raw editor.' }) + fireEvent.focus(warning) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Colliding properties') + + fireEvent.click(warning) + expect(onToggleRawEditor).toHaveBeenCalledOnce() + }) + it('shows properties when a note is selected', () => { render() expect(screen.getAllByText('Project').length).toBeGreaterThan(0) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 9fb40926..fc6c2b2b 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react' import type { VaultEntry, GitCommit } from '../types' import { cn } from '@/lib/utils' import { Separator } from './ui/separator' -import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter' +import { parseFrontmatter, detectFrontmatterState, detectFrontmatterWarnings } from '../utils/frontmatter' import { DynamicPropertiesPanel } from './DynamicPropertiesPanel' import { DynamicRelationshipsPanel, @@ -247,9 +247,20 @@ function InspectorBody({ } export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) { + const frontmatterWarnings = useMemo( + () => detectFrontmatterWarnings(bodyProps.content), + [bodyProps.content], + ) + return (