From e9c1ae2b8a8afb35c05d1055f8aa396bafa5ce7d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 16 Apr 2026 06:21:34 +0200 Subject: [PATCH] fix: canonicalize hidden system metadata --- src-tauri/src/frontmatter/ops.rs | 159 +++- src-tauri/src/vault/frontmatter.rs | 80 +- src-tauri/src/vault/mod.rs | 3 + src-tauri/src/vault/system_metadata_tests.rs | 73 ++ ...micPropertiesPanel.systemMetadata.test.tsx | 127 +++ .../DynamicPropertiesPanel.test.tsx | 806 ++++-------------- src/components/DynamicPropertiesPanel.tsx | 27 +- .../frontmatterOps.systemMetadata.test.ts | 40 + src/hooks/frontmatterOps.ts | 58 +- src/hooks/usePropertyPanelState.ts | 38 +- src/utils/systemMetadata.ts | 39 + 11 files changed, 700 insertions(+), 750 deletions(-) create mode 100644 src-tauri/src/vault/system_metadata_tests.rs create mode 100644 src/components/DynamicPropertiesPanel.systemMetadata.test.tsx create mode 100644 src/hooks/frontmatterOps.systemMetadata.test.ts create mode 100644 src/utils/systemMetadata.ts diff --git a/src-tauri/src/frontmatter/ops.rs b/src-tauri/src/frontmatter/ops.rs index c48d2b16..dbbce0e7 100644 --- a/src-tauri/src/frontmatter/ops.rs +++ b/src-tauri/src/frontmatter/ops.rs @@ -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, + value: Option<&FrontmatterValue>, ) -> Result { 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, +) -> Result { + 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, + 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")); diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 57526e84..1f4983b9 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -21,17 +21,22 @@ pub(crate) struct Frontmatter { pub archived: Option, #[serde(rename = "Status", alias = "status", default)] pub status: Option, - #[serde(default)] + #[serde(rename = "_icon", alias = "icon", default)] pub icon: Option, #[serde(default)] pub color: Option, - #[serde(default)] + #[serde(rename = "_order", alias = "order", default)] pub order: Option, - #[serde(rename = "sidebar label", default)] + #[serde( + rename = "_sidebar_label", + alias = "sidebar label", + alias = "sidebar_label", + default + )] pub sidebar_label: Option, #[serde(default)] pub template: Option, - #[serde(default)] + #[serde(rename = "_sort", alias = "sort", default)] pub sort: Option, #[serde(default)] pub view: Option, @@ -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 { + match item { + serde_json::Value::Null => None, + serde_json::Value::Object(map) if !map.is_empty() => { + let parts: Vec = 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 = 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 = 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 = + 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) -> 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) -> 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, @@ -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(':')?; diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index b54d5438..e7810b4a 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -454,5 +454,8 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result, 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; diff --git a/src-tauri/src/vault/system_metadata_tests.rs b/src-tauri/src/vault/system_metadata_tests.rs new file mode 100644 index 00000000..c9c71374 --- /dev/null +++ b/src-tauri/src/vault/system_metadata_tests.rs @@ -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") + ); +} diff --git a/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx b/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx new file mode 100644 index 00000000..cfb12751 --- /dev/null +++ b/src/components/DynamicPropertiesPanel.systemMetadata.test.tsx @@ -0,0 +1,127 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { DynamicPropertiesPanel } from './DynamicPropertiesPanel' +import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents' +import type { VaultEntry } from '../types' + +beforeAll(() => { + global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = () => false + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + if (!window.getComputedStyle) window.getComputedStyle = vi.fn().mockReturnValue({}) as never +}) + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 0, + createdAt: 0, + fileSize: 0, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', + ...overrides, +}) + +function hasSuggestedSlot(label: string): boolean { + return screen + .queryAllByTestId('suggested-property') + .some((node) => node.textContent?.includes(label)) +} + +describe('DynamicPropertiesPanel system metadata', () => { + const onAddProperty = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('hides underscored and legacy system metadata while keeping user properties visible', () => { + render( + , + ) + + expect(screen.getByText('Owner')).toBeInTheDocument() + expect(screen.getByText('Luca')).toBeInTheDocument() + expect(screen.queryByText('Icon')).not.toBeInTheDocument() + expect(screen.queryByText('Order')).not.toBeInTheDocument() + expect(screen.queryByText('Sort')).not.toBeInTheDocument() + expect(screen.queryByText('Sidebar label')).not.toBeInTheDocument() + }) + + it('treats _icon as satisfying the suggested icon slot', () => { + render( + , + ) + + expect(hasSuggestedSlot('Icon')).toBe(false) + }) + + it('opens the icon editor without writing metadata until the user saves', async () => { + render( + , + ) + + act(() => { + window.dispatchEvent(new CustomEvent(FOCUS_NOTE_ICON_PROPERTY_EVENT)) + }) + + await waitFor(() => { + expect(screen.getByTestId('icon-editable-input')).toBeInTheDocument() + }) + expect(onAddProperty).not.toHaveBeenCalled() + + const input = screen.getByTestId('icon-editable-input') + fireEvent.change(input, { target: { value: 'rocket' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onAddProperty).toHaveBeenCalledWith('_icon', 'rocket') + }) +}) diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 98c875c8..6d788963 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel' @@ -42,6 +43,28 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ ...overrides, }) +type DynamicPropertiesPanelProps = ComponentProps +type RenderPanelOptions = Omit & { + entry?: VaultEntry + content?: string + frontmatter?: Record +} + +const renderPanel = ({ + entry = makeEntry(), + content = '', + frontmatter = {}, + ...props +}: RenderPanelOptions = {}) => + render( + , + ) + describe('containsWikilinks', () => { it('returns true for string wikilinks', () => { expect(containsWikilinks('[[My Note]]')).toBe(true) @@ -78,39 +101,38 @@ describe('DynamicPropertiesPanel', () => { vi.clearAllMocks() }) + function renderEditablePanel(frontmatter: Record) { + renderPanel({ frontmatter, onUpdateProperty }) + } + + function openAddPropertyForm(options: RenderPanelOptions = {}) { + renderPanel({ onAddProperty, ...options }) + fireEvent.click(screen.getByText('Add property')) + + return { + keyInput: screen.getByPlaceholderText('Property name'), + valueInput: screen.getByPlaceholderText('Value'), + } + } + it('renders type row', () => { - render( - - ) + renderPanel({ + content: '# Test\n\nSome words here', + frontmatter: { Status: 'Active' }, + onUpdateProperty, + }) expect(screen.getByText('Type')).toBeInTheDocument() expect(screen.getByText('Note')).toBeInTheDocument() }) it('renders status as colored pill', () => { - render( - - ) + renderPanel({ frontmatter: { Status: 'Active' } }) // Status rendered as sentence case expect(screen.getByTestId('status-badge')).toBeInTheDocument() }) it('renders properties from frontmatter', () => { - render( - - ) + renderPanel({ frontmatter: { cadence: 'Weekly', owner: 'Luca' } }) expect(screen.getByText('Cadence')).toBeInTheDocument() expect(screen.getByText('Weekly')).toBeInTheDocument() expect(screen.getByText('Owner')).toBeInTheDocument() @@ -118,73 +140,45 @@ describe('DynamicPropertiesPanel', () => { }) it('renders capitalized Owner with plain text value in Properties panel', () => { - render( - - ) + renderPanel({ frontmatter: { Owner: 'Luca' } }) expect(screen.getByText('Owner')).toBeInTheDocument() expect(screen.getByText('Luca')).toBeInTheDocument() }) it('left-aligns mixed property value displays', () => { - render( - , - ) + renderPanel({ + frontmatter: { + Owner: 'Luca', + History_confidence: 0.84, + Date: '2026-04-11', + color: '#3b82f6', + Window_end: null, + }, + }) expect(screen.getByText('Luca').parentElement).toHaveClass('justify-start', 'text-left') expect(screen.getByText('0.84').parentElement).toHaveClass('justify-start', 'text-left') expect(screen.getByTestId('date-display')).toHaveClass('text-left') - expect(screen.getByTestId('icon-editable-display')).toHaveClass('text-left') expect(screen.getByText('#3b82f6')).toHaveClass('text-left') expect(screen.getByText('\u2014').parentElement).toHaveClass('justify-start', 'text-left') }) it('hides Owner with wikilink value from Properties panel', () => { - render( - - ) + renderPanel({ frontmatter: { Owner: '[[person/luca]]' } }) // Owner with wikilink goes to RelationshipsPanel, not Properties expect(screen.queryByText('Owner')).not.toBeInTheDocument() }) it('renders notion_id as a visible property', () => { - render( - - ) + renderPanel({ frontmatter: { notion_id: 'abc-123-def' } }) expect(screen.getByText('Notion id')).toBeInTheDocument() expect(screen.getByText('abc-123-def')).toBeInTheDocument() }) it('skips aliases and fields with wikilink values', () => { - render( - - ) + renderPanel({ + frontmatter: { aliases: ['AL'], 'Belongs to': '[[Something]]', cadence: 'Monthly' }, + }) // aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks) expect(screen.queryByText('aliases')).not.toBeInTheDocument() expect(screen.queryByText('Belongs to')).not.toBeInTheDocument() @@ -192,38 +186,22 @@ describe('DynamicPropertiesPanel', () => { }) it('shows former relationship key with plain text value in Properties', () => { - render( - - ) + renderPanel({ frontmatter: { 'Belongs to': 'some-team', cadence: 'Monthly' } }) // 'Belongs to' has a plain text value, not a wikilink — should render as property expect(screen.getByText('Belongs to')).toBeInTheDocument() expect(screen.getByText('some-team')).toBeInTheDocument() }) it('hides custom field with wikilink value from Properties', () => { - render( - - ) + renderPanel({ frontmatter: { Mentor: '[[person/luca]]' } }) // Mentor contains a wikilink → shown in Relationships, not Properties expect(screen.queryByText('Mentor')).not.toBeInTheDocument() }) it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => { - render( - - ) + renderPanel({ + frontmatter: { is_a: 'Note', type: 'Note', 'Is A': 'Note', Status: 'Active' }, + }) expect(screen.queryByText('is_a')).not.toBeInTheDocument() expect(screen.queryByText('Is A')).not.toBeInTheDocument() // 'type' as a property label should not appear (the TypeRow renders 'Type' differently) @@ -234,14 +212,7 @@ describe('DynamicPropertiesPanel', () => { }) it('renders boolean property as toggle', () => { - render( - - ) + renderEditablePanel({ published: false }) // Boolean should show as Yes/No toggle const toggleBtn = screen.getByText('No') fireEvent.click(toggleBtn) @@ -249,58 +220,26 @@ describe('DynamicPropertiesPanel', () => { }) it('renders array property as tag pills', () => { - render( - - ) + renderEditablePanel({ tags: ['ai', 'ml', 'deep-learning'] }) expect(screen.getByText('ai')).toBeInTheDocument() expect(screen.getByText('ml')).toBeInTheDocument() expect(screen.getByText('deep-learning')).toBeInTheDocument() }) it('shows Add property button', () => { - render( - - ) + renderPanel({ onAddProperty }) expect(screen.getByText('Add property')).toBeInTheDocument() }) it('opens add property form when button clicked', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument() - expect(screen.getByPlaceholderText('Value')).toBeInTheDocument() + const { keyInput, valueInput } = openAddPropertyForm() + expect(keyInput).toBeInTheDocument() + expect(valueInput).toBeInTheDocument() expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument() }) it('adds property via the add form', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - const keyInput = screen.getByPlaceholderText('Property name') - const valueInput = screen.getByPlaceholderText('Value') + const { keyInput, valueInput } = openAddPropertyForm() fireEvent.change(keyInput, { target: { value: 'priority' } }) fireEvent.change(valueInput, { target: { value: 'high' } }) fireEvent.click(screen.getByTestId('add-property-confirm')) @@ -328,23 +267,13 @@ describe('DynamicPropertiesPanel', () => { ] it('renders as dropdown when editable', () => { - render( - - ) + renderPanel({ entries: typeEntries, onUpdateProperty }) expect(screen.getByTestId('type-selector')).toBeInTheDocument() expect(screen.getByRole('combobox')).toBeInTheDocument() }) it('shows available types in dropdown', () => { - render( - - ) + renderPanel({ entries: typeEntries, onUpdateProperty }) fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) expect(screen.getByRole('option', { name: 'None' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'Person' })).toBeInTheDocument() @@ -358,59 +287,44 @@ describe('DynamicPropertiesPanel', () => { } it('calls onUpdateProperty when type selected', () => { - render( - - ) + renderPanel({ entries: typeEntries, onUpdateProperty }) openAndSelect('Project') expect(onUpdateProperty).toHaveBeenCalledWith('type', 'Project') }) it('clears type when None selected', () => { - render( - - ) + renderPanel({ + entry: makeEntry({ isA: 'Project' }), + entries: typeEntries, + onUpdateProperty, + }) openAndSelect('None') expect(onUpdateProperty).toHaveBeenCalledWith('type', null) }) it('shows current type even when not in available types', () => { - render( - - ) + renderPanel({ + entry: makeEntry({ isA: 'CustomType' }), + entries: typeEntries, + onUpdateProperty, + }) fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument() }) it('shows None placeholder when entry has no type', () => { - render( - - ) + renderPanel({ + entry: makeEntry({ isA: null }), + entries: typeEntries, + onUpdateProperty, + }) expect(screen.getByTestId('type-selector')).toBeInTheDocument() expect(screen.getByText('None')).toBeInTheDocument() }) }) it('opens status dropdown on click and selects a status', () => { - render( - - ) + renderEditablePanel({ Status: 'Active' }) // Click status pill to open dropdown fireEvent.click(screen.getByTestId('status-badge')) // Should show dropdown with search input @@ -422,29 +336,18 @@ describe('DynamicPropertiesPanel', () => { }) it('deletes property when delete button clicked', () => { - render( - - ) + renderPanel({ + frontmatter: { custom_field: 'value' }, + onDeleteProperty, + onUpdateProperty, + }) const deleteBtn = screen.getByTitle('Delete property') fireEvent.click(deleteBtn) expect(onDeleteProperty).toHaveBeenCalledWith('custom_field') }) it('coerces true/false strings to booleans on save', () => { - render( - - ) + renderEditablePanel({ draft: 'false' }) // Edit the value fireEvent.click(screen.getByText('false')) const input = screen.getByDisplayValue('false') @@ -454,49 +357,23 @@ describe('DynamicPropertiesPanel', () => { }) it('coerces numeric strings to numbers on save', () => { - render( - - ) + renderEditablePanel({ priority: '3' }) fireEvent.click(screen.getByText('3')) const input = screen.getByDisplayValue('3') fireEvent.change(input, { target: { value: '5' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(onUpdateProperty).toHaveBeenCalledWith('order', 5) + expect(onUpdateProperty).toHaveBeenCalledWith('priority', 5) }) it('cancels add form on Escape', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - const keyInput = screen.getByPlaceholderText('Property name') + const { keyInput } = openAddPropertyForm() fireEvent.keyDown(keyInput, { key: 'Escape' }) // Form should be hidden, button should reappear expect(screen.getByText('Add property')).toBeInTheDocument() }) it('adds property on Enter in form', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - const keyInput = screen.getByPlaceholderText('Property name') - const valueInput = screen.getByPlaceholderText('Value') + const { keyInput, valueInput } = openAddPropertyForm() fireEvent.change(keyInput, { target: { value: 'key' } }) fireEvent.change(valueInput, { target: { value: 'val' } }) fireEvent.keyDown(valueInput, { key: 'Enter' }) @@ -504,17 +381,7 @@ describe('DynamicPropertiesPanel', () => { }) it('handles comma-separated values as array', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - const keyInput = screen.getByPlaceholderText('Property name') - const valueInput = screen.getByPlaceholderText('Value') + const { keyInput, valueInput } = openAddPropertyForm() fireEvent.change(keyInput, { target: { value: 'tags' } }) fireEvent.change(valueInput, { target: { value: 'a, b, c' } }) fireEvent.keyDown(valueInput, { key: 'Enter' }) @@ -522,30 +389,18 @@ describe('DynamicPropertiesPanel', () => { }) it('handles cancel button in add form', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() fireEvent.click(screen.getByTestId('add-property-cancel')) expect(screen.getByText('Add property')).toBeInTheDocument() }) describe('editable vs read-only distinction', () => { it('editable properties have hover styling via data-testid', () => { - render( - - ) + renderPanel({ + frontmatter: { cadence: 'Weekly', owner: 'Luca' }, + onUpdateProperty, + onDeleteProperty, + }) const editableRows = screen.getAllByTestId('editable-property') expect(editableRows.length).toBe(2) // Editable rows have hover:bg-muted class for interactivity @@ -557,14 +412,7 @@ describe('DynamicPropertiesPanel', () => { describe('property row 50/50 layout', () => { it('uses CSS grid with two equal columns on editable rows', () => { - render( - - ) + renderEditablePanel({ url: 'https://example.com/very/long/path/that/should/be/truncated' }) const editableRows = screen.getAllByTestId('editable-property') editableRows.forEach(row => { expect(row.className).toContain('grid') @@ -581,14 +429,7 @@ describe('DynamicPropertiesPanel', () => { } it('shows Status/Date/URL/Icon slots when no properties exist and onAddProperty provided', () => { - render( - - ) + renderPanel({ onAddProperty }) const slots = screen.getAllByTestId('suggested-property') expect(slots.length).toBe(4) expect(screen.getByText('Status')).toBeInTheDocument() @@ -598,53 +439,32 @@ describe('DynamicPropertiesPanel', () => { }) it('hides Status slot when Status property already exists', () => { - render( - - ) + renderPanel({ + frontmatter: { Status: 'Active' }, + onAddProperty, + onUpdateProperty, + }) const slots = screen.getAllByTestId('suggested-property') expect(slots.length).toBe(3) expect(screen.queryAllByText('Status').some(el => el.closest('[data-testid="suggested-property"]'))).toBe(false) }) it('hides all slots when all suggested properties exist', () => { - render( - - ) + renderPanel({ + frontmatter: { Status: 'Active', Date: '2024-01-01', URL: 'https://example.com', icon: 'star' }, + onAddProperty, + onUpdateProperty, + }) expect(screen.queryByTestId('suggested-property')).not.toBeInTheDocument() }) it('does not show slots when onAddProperty is not provided', () => { - render( - - ) + renderPanel() expect(screen.queryByTestId('suggested-property')).not.toBeInTheDocument() }) it('opens the status editor without writing an empty property when clicking a suggested slot', async () => { - render( - - ) + renderPanel({ onAddProperty }) fireEvent.click(findSuggestedSlot('Status')) expect(onAddProperty).not.toHaveBeenCalled() await waitFor(() => { @@ -653,15 +473,7 @@ describe('DynamicPropertiesPanel', () => { }) it('writes the suggested status only after the user picks a value', async () => { - render( - - ) + renderPanel({ onAddProperty, onUpdateProperty }) fireEvent.click(findSuggestedSlot('Status')) await waitFor(() => { @@ -673,14 +485,7 @@ describe('DynamicPropertiesPanel', () => { }) it('cancels a suggested status edit without writing frontmatter', async () => { - render( - - ) + renderPanel({ onAddProperty }) fireEvent.click(findSuggestedSlot('Status')) await waitFor(() => { @@ -693,14 +498,7 @@ describe('DynamicPropertiesPanel', () => { }) it('opens the date picker without writing an empty property when clicking the Date slot', async () => { - render( - - ) + renderPanel({ onAddProperty }) fireEvent.click(findSuggestedSlot('Date')) expect(onAddProperty).not.toHaveBeenCalled() @@ -712,27 +510,13 @@ describe('DynamicPropertiesPanel', () => { describe('URL property rendering', () => { it('renders URL values with link styling instead of plain EditableValue', () => { - render( - - ) + renderEditablePanel({ url: 'https://example.com' }) expect(screen.getByTestId('url-link')).toBeInTheDocument() expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com') }) it('gives long URL values a truncating value-cell layout inside the properties panel', () => { - render( - - ) + renderEditablePanel({ url: 'https://example.com/very/long/path/that/should/truncate' }) const link = screen.getByTestId('url-link') const value = screen.getByText('https://example.com/very/long/path/that/should/truncate') @@ -741,50 +525,22 @@ describe('DynamicPropertiesPanel', () => { }) it('renders bare domain values as URL links', () => { - render( - - ) + renderEditablePanel({ website: 'example.com' }) expect(screen.getByTestId('url-link')).toBeInTheDocument() }) it('does not render plain text as URL link', () => { - render( - - ) + renderEditablePanel({ cadence: 'Weekly' }) expect(screen.queryByTestId('url-link')).not.toBeInTheDocument() }) it('shows edit button on URL property', () => { - render( - - ) + renderEditablePanel({ url: 'https://example.com' }) expect(screen.getByTestId('url-edit-btn')).toBeInTheDocument() }) it('enters edit mode when edit button clicked on URL property', () => { - render( - - ) + renderEditablePanel({ url: 'https://example.com' }) fireEvent.click(screen.getByTestId('url-edit-btn')) expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() }) @@ -792,40 +548,19 @@ describe('DynamicPropertiesPanel', () => { describe('smart property display — date', () => { it('renders date property with friendly format', () => { - render( - - ) + renderEditablePanel({ deadline: '2026-03-31' }) expect(screen.getByTestId('date-display')).toBeInTheDocument() expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument() }) it('renders date trigger button', () => { - render( - - ) + renderEditablePanel({ deadline: '2026-03-31' }) const trigger = screen.getByTestId('date-display') expect(trigger.tagName).toBe('BUTTON') }) it('opens calendar popover when date button clicked', () => { - render( - - ) + renderEditablePanel({ deadline: '2026-03-31' }) fireEvent.click(screen.getByTestId('date-display')) expect(screen.getByTestId('date-picker-popover')).toBeInTheDocument() // Clear button is inside the popover portal @@ -835,40 +570,19 @@ describe('DynamicPropertiesPanel', () => { describe('smart property display — status auto-detection', () => { it('renders status badge for property named Status', () => { - render( - - ) + renderEditablePanel({ Status: 'Active' }) expect(screen.getByTestId('status-badge')).toBeInTheDocument() }) it('renders status badge for known status values', () => { - render( - - ) + renderEditablePanel({ phase: 'Draft' }) expect(screen.getByTestId('status-badge')).toBeInTheDocument() }) }) describe('status dropdown interaction', () => { it('closes dropdown on Escape without saving', () => { - render( - - ) + renderEditablePanel({ Status: 'Active' }) fireEvent.click(screen.getByTestId('status-badge')) expect(screen.getByTestId('status-dropdown')).toBeInTheDocument() fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' }) @@ -877,14 +591,7 @@ describe('DynamicPropertiesPanel', () => { }) it('closes dropdown on backdrop click without saving', () => { - render( - - ) + renderEditablePanel({ Status: 'Active' }) fireEvent.click(screen.getByTestId('status-badge')) fireEvent.click(screen.getByTestId('status-dropdown-backdrop')) expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument() @@ -892,14 +599,7 @@ describe('DynamicPropertiesPanel', () => { }) it('creates custom status by typing and pressing Enter', () => { - render( - - ) + renderEditablePanel({ Status: 'Active' }) fireEvent.click(screen.getByTestId('status-badge')) const input = screen.getByTestId('status-search-input') fireEvent.change(input, { target: { value: 'Needs Review' } }) @@ -912,15 +612,11 @@ describe('DynamicPropertiesPanel', () => { makeEntry({ path: '/vault/a.md', status: 'Reviewing' }), makeEntry({ path: '/vault/b.md', status: 'Shipped' }), ] - render( - - ) + renderPanel({ + frontmatter: { Status: 'Active' }, + entries: entriesWithStatuses, + onUpdateProperty, + }) fireEvent.click(screen.getByTestId('status-badge')) expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument() expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument() @@ -929,96 +625,44 @@ describe('DynamicPropertiesPanel', () => { describe('smart property display — boolean', () => { it('renders boolean toggle for true values', () => { - render( - - ) + renderEditablePanel({ published: true }) expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() expect(screen.getByText('Yes')).toBeInTheDocument() }) it('renders boolean toggle for false values', () => { - render( - - ) + renderEditablePanel({ published: false }) expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() expect(screen.getByText('No')).toBeInTheDocument() }) }) describe('system property filtering', () => { - it('hides archived and archived_at but keeps icon visible in properties panel', () => { - render( - - ) + it('hides archived, archived_at, and legacy icon metadata from the properties panel', () => { + renderEditablePanel({ archived: false, archived_at: '', icon: '📝', cadence: 'Weekly' }) expect(screen.queryByText('Archived')).not.toBeInTheDocument() expect(screen.queryByText('Archived at')).not.toBeInTheDocument() - expect(screen.getByText('Icon')).toBeInTheDocument() - expect(screen.getByText('📝')).toBeInTheDocument() + expect(screen.queryByText('Icon')).not.toBeInTheDocument() + expect(screen.queryByText('📝')).not.toBeInTheDocument() // Custom property still visible expect(screen.getByText('Cadence')).toBeInTheDocument() }) - it('keeps icon visible even when cased differently', () => { - render( - - ) + it('hides legacy icon metadata even when cased differently', () => { + renderEditablePanel({ Archived: false, Icon: '🎯', cadence: 'Daily' }) expect(screen.queryByText('Archived')).not.toBeInTheDocument() - expect(screen.getByText('Icon')).toBeInTheDocument() - expect(screen.getByText('🎯')).toBeInTheDocument() + expect(screen.queryByText('Icon')).not.toBeInTheDocument() + expect(screen.queryByText('🎯')).not.toBeInTheDocument() expect(screen.getByText('Cadence')).toBeInTheDocument() }) it('does not filter similar but non-matching property names', () => { - render( - - ) + renderEditablePanel({ 'Is Trashed': true, archive_date: '2026-01-01' }) expect(screen.getByText('Is Trashed')).toBeInTheDocument() expect(screen.getByText('Archive date')).toBeInTheDocument() }) }) - describe('icon property rendering', () => { - it('keeps icon values in plain text mode even when they are urls', () => { - render( - - ) - - expect(screen.getByText('Icon')).toBeInTheDocument() - expect(screen.getByText('https://example.com/favicon.png')).toBeInTheDocument() - expect(screen.queryByTestId('url-link')).not.toBeInTheDocument() - }) - }) - describe('display mode override', () => { beforeEach(() => { resetVaultConfigStore() @@ -1030,26 +674,12 @@ describe('DynamicPropertiesPanel', () => { }) it('renders display mode trigger on property rows', () => { - render( - - ) + renderEditablePanel({ cadence: 'Weekly' }) expect(screen.getByTestId('display-mode-trigger')).toBeInTheDocument() }) it('opens display mode menu on trigger click', () => { - render( - - ) + renderEditablePanel({ cadence: 'Weekly' }) fireEvent.click(screen.getByTestId('display-mode-trigger')) expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument() expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument() @@ -1060,14 +690,7 @@ describe('DynamicPropertiesPanel', () => { }) it('persists override to vault config when mode selected', () => { - render( - - ) + renderEditablePanel({ cadence: 'Weekly' }) fireEvent.click(screen.getByTestId('display-mode-trigger')) fireEvent.click(screen.getByTestId('display-mode-option-status')) const stored = getVaultConfig().property_display_modes as Record @@ -1077,98 +700,48 @@ describe('DynamicPropertiesPanel', () => { it('overrides rendering to status badge when status mode selected', () => { initDisplayModeOverrides({ cadence: 'status' }) - render( - - ) + renderEditablePanel({ cadence: 'Weekly' }) expect(screen.getByTestId('status-badge')).toBeInTheDocument() }) it('renders boolean toggle for string "true" when boolean mode overridden', () => { initDisplayModeOverrides({ draft: 'boolean' }) - render( - - ) + renderEditablePanel({ draft: 'true' }) expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() expect(screen.getByText('Yes')).toBeInTheDocument() }) it('renders boolean toggle for string "false" when boolean mode overridden', () => { initDisplayModeOverrides({ draft: 'boolean' }) - render( - - ) + renderEditablePanel({ draft: 'false' }) expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument() expect(screen.getByText('No')).toBeInTheDocument() }) it('toggles string boolean from false to true', () => { initDisplayModeOverrides({ draft: 'boolean' }) - render( - - ) + renderEditablePanel({ draft: 'false' }) fireEvent.click(screen.getByTestId('boolean-toggle')) expect(onUpdateProperty).toHaveBeenCalledWith('draft', true) }) it('renders date picker for empty value when date mode overridden', () => { initDisplayModeOverrides({ due: 'date' }) - render( - - ) + renderEditablePanel({ due: '' }) expect(screen.getByTestId('date-display')).toBeInTheDocument() expect(screen.getByText('Pick a date\u2026')).toBeInTheDocument() }) it('renders date picker for non-date string when date mode overridden', () => { initDisplayModeOverrides({ deadline: 'date' }) - render( - - ) + renderEditablePanel({ deadline: 'soon' }) expect(screen.getByTestId('date-display')).toBeInTheDocument() }) }) describe('type-aware add property form', () => { it('shows boolean toggle when boolean type selected', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() // Switch type to boolean fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: /Boolean/ })) @@ -1177,15 +750,7 @@ describe('DynamicPropertiesPanel', () => { }) it('toggles boolean value in add form', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: /Boolean/ })) // Toggle from No to Yes @@ -1194,16 +759,7 @@ describe('DynamicPropertiesPanel', () => { }) it('stores actual boolean value when adding boolean property', { timeout: 15_000 }, () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) - const keyInput = screen.getByPlaceholderText('Property name') + const { keyInput } = openAddPropertyForm() fireEvent.change(keyInput, { target: { value: 'published' } }) // Switch to boolean type fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' }) @@ -1216,15 +772,7 @@ describe('DynamicPropertiesPanel', () => { }) it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: /Date/ })) expect(screen.getByTestId('add-property-date-trigger')).toBeInTheDocument() @@ -1232,30 +780,14 @@ describe('DynamicPropertiesPanel', () => { }) it('shows status dropdown when status type selected', { timeout: 15_000 }, () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: /Status/ })) expect(screen.getByTestId('add-property-status-trigger')).toBeInTheDocument() }) it('shows text input for text and url types', () => { - render( - - ) - fireEvent.click(screen.getByText('Add property')) + openAddPropertyForm() // Default mode is text expect(screen.getByPlaceholderText('Value')).toBeInTheDocument() }) diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 69003867..374eadc6 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -11,6 +11,7 @@ import type { PropertyDisplayMode } from '../utils/propertyTypes' import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents' import { PROPERTY_PANEL_GRID_STYLE, PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout' import { humanizePropertyKey } from '../utils/propertyLabels' +import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata' // eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function containsWikilinks(value: FrontmatterValue): boolean { @@ -107,6 +108,7 @@ function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => v function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set { const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase())) for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase()) + if (hasSystemMetadataKey(keys, '_icon')) keys.add('icon') return keys } @@ -118,34 +120,25 @@ function getMissingSuggestedProperties(canAddProperty: boolean, existingKeys: Se ) } -function getIconPropertyKey(propertyEntries: [string, FrontmatterValue][]) { - return propertyEntries.find(([key]) => key.toLowerCase() === 'icon')?.[0] -} - function useFocusNoteIconProperty({ onAddProperty, - propertyEntries, setEditingKey, + setPendingSuggestedKey, }: { onAddProperty?: (key: string, value: FrontmatterValue) => void - propertyEntries: [string, FrontmatterValue][] setEditingKey: (key: string | null) => void + setPendingSuggestedKey: (key: string | null) => void }) { useEffect(() => { const handleFocusNoteIcon = () => { - const existingIconKey = getIconPropertyKey(propertyEntries) - - if (!existingIconKey) { - if (!onAddProperty) return - onAddProperty('icon', '') - } - - setEditingKey(existingIconKey ?? 'icon') + if (!onAddProperty) return + setPendingSuggestedKey('icon') + setEditingKey('icon') } window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon) return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon) - }, [onAddProperty, propertyEntries, setEditingKey]) + }, [onAddProperty, setEditingKey, setPendingSuggestedKey]) } export function DynamicPropertiesPanel({ @@ -195,10 +188,10 @@ export function DynamicPropertiesPanel({ if (!trimmed) { return } - onAddProperty(key, trimmed) + onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed) }, [onAddProperty, setEditingKey]) - useFocusNoteIconProperty({ onAddProperty, propertyEntries, setEditingKey }) + useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey }) return (
diff --git a/src/hooks/frontmatterOps.systemMetadata.test.ts b/src/hooks/frontmatterOps.systemMetadata.test.ts new file mode 100644 index 00000000..fae5f394 --- /dev/null +++ b/src/hooks/frontmatterOps.systemMetadata.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { VaultEntry } from '../types' +import { contentToEntryPatch, frontmatterToEntryPatch } from './frontmatterOps' + +describe('frontmatterOps system metadata', () => { + it.each([ + ['_icon', 'rocket', { icon: 'rocket' }], + ['icon', 'rocket', { icon: 'rocket' }], + ['_order', 4, { order: 4 }], + ['order', 4, { order: 4 }], + ['_sort', 'title:asc', { sort: 'title:asc' }], + ['sort', 'title:asc', { sort: 'title:asc' }], + ['_sidebar_label', 'Projects', { sidebarLabel: 'Projects' }], + ['sidebar label', 'Projects', { sidebarLabel: 'Projects' }], + ] as [string, unknown, Partial][])('maps %s to the expected entry field', (key, value, expected) => { + expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected) + }) + + it('maps canonical delete keys for system metadata', () => { + expect(frontmatterToEntryPatch('delete', '_icon').patch).toEqual({ icon: null }) + expect(frontmatterToEntryPatch('delete', '_sidebar_label').patch).toEqual({ sidebarLabel: null }) + expect(frontmatterToEntryPatch('delete', '_order').patch).toEqual({ order: null }) + expect(frontmatterToEntryPatch('delete', '_sort').patch).toEqual({ sort: null }) + }) + + it('keeps canonical system metadata out of custom properties', () => { + const patch = contentToEntryPatch( + '---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n_internal: secret\nOwner: Luca\n---\n# Project\n', + ) + + expect(patch).toEqual({ + isA: 'Type', + icon: 'rocket', + order: 4, + sidebarLabel: 'Projects', + sort: 'title:asc', + properties: { Owner: 'Luca' }, + }) + }) +}) diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index a016c5f2..0967271b 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -5,15 +5,16 @@ import type { FrontmatterValue } from '../components/Inspector' import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers' import { updateMockContent, trackMockChange } from '../mock-tauri' import { parseFrontmatter } from '../utils/frontmatter' +import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata' const ENTRY_DELETE_MAP: Record> = { title: { title: '' }, type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null }, - icon: { icon: null }, sidebar_label: { sidebarLabel: null }, + _icon: { icon: null }, _sidebar_label: { sidebarLabel: null }, aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] }, _archived: { archived: false }, archived: { archived: false }, - order: { order: null }, - template: { template: null }, sort: { sort: null }, visible: { visible: null }, + _order: { order: null }, + template: { template: null }, _sort: { sort: null }, visible: { visible: null }, _organized: { organized: false }, _favorite: { favorite: false }, _favorite_index: { favoriteIndex: null }, _list_properties_display: { listPropertiesDisplay: [] }, @@ -47,27 +48,40 @@ export interface EntryPatchResult { propertiesPatch: PropertiesPatch | null } +function applyRecordPatch( + existing: Record, + patch: Record, +): Record { + const merged = { ...existing } + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete merged[key] + else merged[key] = value + } + return merged +} + /** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ export function frontmatterToEntryPatch( op: 'update' | 'delete', key: string, value?: FrontmatterValue, ): EntryPatchResult { - const k = key.toLowerCase().replace(/\s+/g, '_') + const lookupKey = canonicalSystemMetadataKey(key) + const systemMetadataKey = isSystemMetadataKey(key) if (op === 'delete') { - const relPatch: RelationshipPatch = { [key]: null } - const propPatch: PropertiesPatch | null = !(k in ENTRY_DELETE_MAP) ? { [key]: null } : null - return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch, propertiesPatch: propPatch } + const relationshipPatch = systemMetadataKey ? null : { [key]: null } + const propertiesPatch = !systemMetadataKey && !(lookupKey in ENTRY_DELETE_MAP) ? { [key]: null } : null + return { patch: ENTRY_DELETE_MAP[lookupKey] ?? {}, relationshipPatch, propertiesPatch } } const str = value != null ? String(value) : null const arr = Array.isArray(value) ? value.map(String) : [] const updates: Record> = { title: { title: str ?? '' }, type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str }, - icon: { icon: str }, sidebar_label: { sidebarLabel: str }, + _icon: { icon: str }, _sidebar_label: { sidebarLabel: str }, aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr }, _archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) }, - order: { order: typeof value === 'number' ? value : null }, + _order: { order: typeof value === 'number' ? value : null }, template: { template: str }, - sort: { sort: str }, + _sort: { sort: str }, view: { view: str }, visible: { visible: value === false ? false : null }, _organized: { organized: Boolean(value) }, @@ -78,12 +92,14 @@ export function frontmatterToEntryPatch( // Also update the relationships map for wikilink-containing values const wikilinks = value != null ? extractWikilinks(value) : [] const relationshipPatch: RelationshipPatch | null = - wikilinks.length > 0 ? { [key]: wikilinks } : null + !systemMetadataKey && wikilinks.length > 0 ? { [key]: wikilinks } : null // For unknown keys (custom properties), produce a propertiesPatch - const isKnownKey = k in updates + const isKnownKey = lookupKey in updates const propertiesPatch: PropertiesPatch | null = - !isKnownKey && value != null ? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) } : null - return { patch: updates[k] ?? {}, relationshipPatch, propertiesPatch } + !systemMetadataKey && !isKnownKey && value != null + ? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) } + : null + return { patch: updates[lookupKey] ?? {}, relationshipPatch, propertiesPatch } } /** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */ @@ -134,24 +150,14 @@ export interface FrontmatterOpOptions { export function applyPropertiesPatch( existing: Record, propPatch: PropertiesPatch, ): Record { - const merged = { ...existing } - for (const [k, v] of Object.entries(propPatch)) { - if (v === null) delete merged[k] - else merged[k] = v - } - return merged + return applyRecordPatch(existing, propPatch) } /** Apply a relationship patch by merging into the existing relationships map. */ export function applyRelationshipPatch( existing: Record, relPatch: RelationshipPatch, ): Record { - const merged = { ...existing } - for (const [k, v] of Object.entries(relPatch)) { - if (v === null) delete merged[k] - else merged[k] = v - } - return merged + return applyRecordPatch(existing, relPatch) } /** Run a frontmatter update/delete and apply the result to state. diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 26d21827..9d9b3b5d 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -9,6 +9,7 @@ import { removeDisplayModeOverride, } from '../utils/propertyTypes' import { containsWikilinks } from '../components/DynamicPropertiesPanel' +import { isSystemMetadataKey } from '../utils/systemMetadata' // Keys to skip showing in Properties (handled by dedicated UI or internal) // Compared case-insensitively via isVisibleProperty() @@ -68,19 +69,40 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined): Record = {} - for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b)) - return result + return toSortedTagRecord(tagsByKey) } function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean { - return !SKIP_KEYS.has(key.toLowerCase()) && !containsWikilinks(value) + return !isHiddenPropertyKey(key) && !containsWikilinks(value) +} + +function addTagValues(tagsByKey: Map>, key: string, value: unknown) { + if (!Array.isArray(value)) return + + let set = tagsByKey.get(key) + if (!set) { + set = new Set() + tagsByKey.set(key, set) + } + + for (const tag of value) { + set.add(String(tag)) + } +} + +function toSortedTagRecord(tagsByKey: Map>): Record { + const result: Record = {} + for (const [key, set] of tagsByKey) { + result[key] = Array.from(set).sort((a, b) => a.localeCompare(b)) + } + return result +} + +function isHiddenPropertyKey(key: string): boolean { + return SKIP_KEYS.has(key.toLowerCase()) || isSystemMetadataKey(key) } function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue { diff --git a/src/utils/systemMetadata.ts b/src/utils/systemMetadata.ts new file mode 100644 index 00000000..32fecd99 --- /dev/null +++ b/src/utils/systemMetadata.ts @@ -0,0 +1,39 @@ +const SYSTEM_METADATA_ALIAS_GROUPS = { + _icon: ['_icon', 'icon'], + _order: ['_order', 'order'], + _sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'], + _sort: ['_sort', 'sort'], +} as const + +const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS) + +const CANONICAL_BY_ALIAS = new Map() + +for (const canonical of CANONICAL_SYSTEM_METADATA_KEYS) { + for (const alias of SYSTEM_METADATA_ALIAS_GROUPS[canonical as keyof typeof SYSTEM_METADATA_ALIAS_GROUPS]) { + CANONICAL_BY_ALIAS.set(alias, canonical) + } +} + +export function normalizePropertyKey(key: string): string { + return key.trim().toLowerCase().replace(/\s+/g, '_') +} + +export function canonicalSystemMetadataKey(key: string): string { + const normalized = normalizePropertyKey(key) + return CANONICAL_BY_ALIAS.get(normalized) ?? normalized +} + +export function isSystemMetadataKey(key: string): boolean { + const normalized = normalizePropertyKey(key) + return normalized.startsWith('_') || CANONICAL_BY_ALIAS.has(normalized) +} + +export function hasSystemMetadataKey(keys: Iterable, canonicalKey: keyof typeof SYSTEM_METADATA_ALIAS_GROUPS): boolean { + for (const key of keys) { + if (canonicalSystemMetadataKey(key) === canonicalKey) { + return true + } + } + return false +}