fix: canonicalize hidden system metadata
This commit is contained in:
40
src/hooks/frontmatterOps.systemMetadata.test.ts
Normal file
40
src/hooks/frontmatterOps.systemMetadata.test.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user