fix: canonicalize hidden system metadata

This commit is contained in:
lucaronin
2026-04-16 06:21:34 +02:00
parent e8e2004aa8
commit e9c1ae2b8a
11 changed files with 700 additions and 750 deletions

View File

@@ -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> = {}): 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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{
_list_properties_display: ['Owner'],
_icon: 'rocket',
icon: 'legacy',
order: 4,
sort: 'title:asc',
'_sidebar_label': 'Projects',
Owner: 'Luca',
}}
/>,
)
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(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ _icon: 'rocket' }}
onAddProperty={onAddProperty}
/>,
)
expect(hasSuggestedSlot('Icon')).toBe(false)
})
it('opens the icon editor without writing metadata until the user saves', async () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
onAddProperty={onAddProperty}
/>,
)
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')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -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<string> {
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 (
<div className="flex flex-col gap-3">

View File

@@ -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<VaultEntry>][])('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' },
})
})
})

View File

@@ -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<string, Partial<VaultEntry>> = {
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<T>(
existing: Record<string, T>,
patch: Record<string, T | null>,
): Record<string, T> {
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<string, Partial<VaultEntry>> = {
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<string, string | number | boolean | null>, propPatch: PropertiesPatch,
): Record<string, string | number | boolean | null> {
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<string, string[]>, relPatch: RelationshipPatch,
): Record<string, string[]> {
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.

View File

@@ -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<string,
for (const entry of entries) {
if (!entry.properties) continue
for (const [key, value] of Object.entries(entry.properties)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
addTagValues(tagsByKey, key, value)
}
}
const result: Record<string, string[]> = {}
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<string, Set<string>>, 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<string, Set<string>>): Record<string, string[]> {
const result: Record<string, string[]> = {}
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 {

View File

@@ -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<string, string>()
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<string>, canonicalKey: keyof typeof SYSTEM_METADATA_ALIAS_GROUPS): boolean {
for (const key of keys) {
if (canonicalSystemMetadataKey(key) === canonicalKey) {
return true
}
}
return false
}