fix: show type-derived instance property placeholders

This commit is contained in:
lucaronin
2026-05-04 10:45:49 +02:00
parent 3c483ba0e6
commit ebf4545d46
15 changed files with 946 additions and 103 deletions

View File

@@ -204,6 +204,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
- Serve as the "definition" for their type category
@@ -222,6 +223,8 @@ Each entity type can have a corresponding **type document**: any markdown note w
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
**UI behavior**:
- Clicking a section group header pins the type document at the top of the NoteList if it exists
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
@@ -681,10 +684,11 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -242,28 +242,53 @@ pub(crate) fn extract_relationships(
continue;
}
match value {
serde_json::Value::String(s) if contains_wikilink(s) => {
relationships.insert(key.clone(), vec![s.clone()]);
}
serde_json::Value::Array(arr) => {
let wikilinks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| contains_wikilink(s))
.map(|s| s.to_string())
.collect();
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
_ => {}
let wikilinks = relationship_wikilinks(value);
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
relationships
}
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
let mut wikilinks = Vec::new();
collect_relationship_wikilinks(value, 0, &mut wikilinks);
wikilinks
}
fn collect_relationship_wikilinks(
value: &serde_json::Value,
depth: usize,
wikilinks: &mut Vec<String>,
) {
match value {
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
serde_json::Value::Array(arr) => {
if let Some(link) = nested_flow_wikilink(arr, depth) {
wikilinks.push(link);
return;
}
for item in arr {
collect_relationship_wikilinks(item, depth + 1, wikilinks);
}
}
_ => {}
}
}
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
if depth == 0 {
return None;
}
match arr {
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
Some(format!("[[{target}]]"))
}
_ => None,
}
}
/// Extract custom scalar properties from raw YAML frontmatter.
pub(crate) fn extract_properties(
data: &HashMap<String, serde_json::Value>,
@@ -276,6 +301,9 @@ pub(crate) fn extract_properties(
}
match value {
serde_json::Value::Null => {
properties.insert(key.clone(), value.clone());
}
serde_json::Value::String(s) if !contains_wikilink(s) => {
properties.insert(key.clone(), value.clone());
}

View File

@@ -114,6 +114,40 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
}
}
#[test]
fn test_blank_scalar_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
);
assert!(entry
.properties
.get("start date")
.is_some_and(|value| value.is_null()));
assert!(entry
.properties
.get("rating")
.is_some_and(|value| value.is_null()));
}
#[test]
fn test_unquoted_wikilink_relationships_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
);
assert_eq!(
entry.relationships.get("Mentor"),
Some(&vec!["[[person/alice]]".to_string()])
);
}
#[test]
fn test_alias_parser_recovers_special_alias_items() {
let cases = [

View File

@@ -188,6 +188,32 @@ describe('DynamicPropertiesPanel', () => {
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-03')
})
it('shows missing type-defined properties as gray editable placeholders', () => {
const typeEntry = makeEntry({
title: 'Book',
isA: 'Type',
properties: {
'start date': null,
},
})
renderPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [typeEntry],
frontmatter: {},
onUpdateProperty,
})
const placeholder = screen.getByTestId('type-derived-property')
expect(within(placeholder).getByText('Start date')).toHaveClass('text-muted-foreground/40')
fireEvent.click(placeholder)
const input = screen.getByDisplayValue('')
fireEvent.change(input, { target: { value: '2026-05-04' } })
fireEvent.blur(input)
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-04')
})
it('hides Owner with wikilink value from Properties panel', () => {
renderPanel({ frontmatter: { Owner: '[[person/luca]]' } })
// Owner with wikilink goes to RelationshipsPanel, not Properties

View File

@@ -153,6 +153,83 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
)
}
function TypeDerivedPropertySlot({
propKey,
editingKey,
displayMode,
autoMode,
vaultStatuses,
vaultTags,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDisplayModeChange,
locale,
}: {
propKey: string
editingKey: string | null
displayMode: PropertyDisplayMode
autoMode: PropertyDisplayMode
vaultStatuses: string[]
vaultTags: string[]
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
if (editingKey === propKey) {
return (
<PropertyRow
propKey={propKey}
value=""
editingKey={editingKey}
displayMode={displayMode}
autoMode={autoMode}
vaultStatuses={vaultStatuses}
vaultTags={vaultTags}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
const PlaceholderIcon = DISPLAY_MODE_ICONS[displayMode]
return (
<Button
type="button"
variant="ghost"
size="sm"
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
style={PROPERTY_PANEL_ROW_STYLE}
onClick={() => onStartEdit(propKey)}
disabled={!onUpdate}
data-testid="type-derived-property"
>
<span className={PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="type-derived-property-icon-slot"
>
<PlaceholderIcon
className="size-3.5 shrink-0 text-muted-foreground/40"
data-testid={`type-derived-property-icon-${displayMode}`}
/>
</span>
<span className="min-w-0 truncate text-muted-foreground/40">{humanizePropertyKey(propKey)}</span>
</span>
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
</Button>
)
}
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())
@@ -229,6 +306,139 @@ function useSuggestedPropertyActions({
}
}
function PropertyEntryRows({
source,
entries,
editingKey,
displayOverrides,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDelete,
onDisplayModeChange,
}: {
source: 'frontmatter' | 'type-derived'
entries: [string, FrontmatterValue][]
editingKey: string | null
displayOverrides: Record<string, PropertyDisplayMode>
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
return (
<>
{entries.map(([key, value]) => (
source === 'type-derived' ? (
<TypeDerivedPropertySlot
key={`type-derived:${key}`}
propKey={key}
editingKey={editingKey}
displayMode={getEffectiveDisplayMode(key, value, displayOverrides)}
autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
) : (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit} onSave={onSave}
onSaveList={onSaveList} onUpdate={onUpdate}
onDelete={onDelete}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
))}
</>
)
}
function PendingSuggestedPropertyRow({
pendingSuggestedKey,
editingKey,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onDisplayModeChange,
}: {
pendingSuggestedKey: string | null
editingKey: string | null
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
if (!pendingSuggestedKey || editingKey !== pendingSuggestedKey) {
return null
}
return (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
function SuggestedPropertyRows({
properties,
onAdd,
}: {
properties: Array<{ key: string; label: string }>
onAdd: (key: string) => void
}) {
return (
<>
{properties.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => onAdd(key)}
/>
))}
</>
)
}
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
@@ -248,12 +458,15 @@ export function DynamicPropertiesPanel({
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
typeDerivedPropertyEntries, handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
const existingKeys = useMemo(
() => getExistingPropertyKeys([...propertyEntries, ...typeDerivedPropertyEntries], frontmatter),
[propertyEntries, typeDerivedPropertyEntries, frontmatter],
)
const missingSuggested = useMemo(
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
[existingKeys, onAddProperty, pendingSuggestedKey],
@@ -285,46 +498,47 @@ export function DynamicPropertiesPanel({
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => handleSuggestedAdd(key)}
/>
))}
<PropertyEntryRows
source="frontmatter"
entries={propertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PropertyEntryRows
source="type-derived"
entries={typeDerivedPropertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveTypeDerivedValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PendingSuggestedPropertyRow
pendingSuggestedKey={pendingSuggestedKey}
editingKey={editingKey}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onDisplayModeChange={handleDisplayModeChange}
/>
<SuggestedPropertyRows properties={missingSuggested} onAdd={handleSuggestedAdd} />
{!showAddDialog && (
<AddPropertyButton
locale={locale}

View File

@@ -96,6 +96,7 @@ function ValidFrontmatterPanels({
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
entry={entry}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}

View File

@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act, within } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
@@ -125,6 +125,35 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByText('Related to')).toBeInTheDocument()
})
it('shows missing type-defined relationships as editable placeholders', () => {
const bookType = makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book',
isA: 'Type',
relationships: {
Mentor: ['[[person/alice]]'],
},
})
renderRelationshipsPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [...entries, bookType],
frontmatter: {},
onAddProperty,
})
const placeholder = screen.getByTestId('type-derived-relationship')
expect(within(placeholder).getByText('Mentor')).toHaveClass('text-muted-foreground/40')
fireEvent.click(within(placeholder).getByTestId('add-relation-ref'))
fireEvent.change(within(placeholder).getByTestId('add-relation-ref-input'), {
target: { value: 'My Project' },
})
fireEvent.keyDown(within(placeholder).getByTestId('add-relation-ref-input'), { key: 'Enter' })
expect(onAddProperty).toHaveBeenCalledWith('Mentor', '[[project/my-project]]')
})
it('navigates when clicking a relationship link', () => {
render(
<DynamicRelationshipsPanel

View File

@@ -17,9 +17,11 @@ import { LinkButton } from './LinkButton'
import {
PROPERTY_PANEL_GRID_STYLE,
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
import { canonicalFrontmatterKey } from '../../utils/systemMetadata'
const RELATIONSHIP_SECTION_ROW_CLASS_NAME = 'flex min-w-0 flex-col gap-1 px-1.5'
const RELATIONSHIPS_PANEL_GRID_CLASS_NAME = 'grid min-w-0 gap-x-2 gap-y-3'
@@ -27,6 +29,7 @@ const RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME = 'min-w-0 flex-1 truncate'
const RELATIONSHIP_SECTION_VALUE_CLASS_NAME = 'min-w-0'
const RELATIONSHIP_ACTION_ROW_CLASS_NAME = 'min-w-0 px-1.5'
const SUGGESTED_RELATIONSHIPS = ['belongs_to', 'related_to', 'has'] as const
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type RelationshipEntryGroup = {
key: string
@@ -65,16 +68,22 @@ interface TitleSelectionState {
trimmed: string
}
function RelationshipSectionRow({ label, children, dataTestId }: {
function RelationshipSectionRow({ label, children, dataTestId, placeholder = false }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
placeholder?: boolean
}) {
const labelClassName = placeholder ? PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME : PROPERTY_PANEL_LABEL_CLASS_NAME
const labelTextClassName = placeholder
? `${RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME} text-muted-foreground/40`
: RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} data-testid="relationship-section-label">
<span className={RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME}>{humanizePropertyKey(label)}</span>
<span className={labelClassName} data-testid="relationship-section-label">
<span className={labelTextClassName}>{humanizePropertyKey(label)}</span>
</span>
<div className={RELATIONSHIP_SECTION_VALUE_CLASS_NAME}>{children}</div>
</div>
@@ -481,6 +490,56 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function findTypeEntry(entries: VaultEntry[], entry?: VaultEntry): VaultEntry | undefined {
if (!entry?.isA || entry.isA === 'Type') return undefined
return entries.find((candidate) => candidate.isA === 'Type' && candidate.title === entry.isA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingRelationshipKeys(frontmatter: ParsedFrontmatter, relationshipEntries: RelationshipEntryGroup[]): Set<string> {
const existingKeys = new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
for (const group of relationshipEntries) existingKeys.add(canonicalFrontmatterKey(group.key))
return existingKeys
}
function buildTypeDerivedRelationshipEntries({
entry,
entries,
frontmatter,
relationshipEntries,
}: {
entry?: VaultEntry
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
relationshipEntries: RelationshipEntryGroup[]
}): RelationshipEntryGroup[] {
const typeEntry = findTypeEntry(entries, entry)
if (!typeEntry) return []
const existingKeys = buildExistingRelationshipKeys(frontmatter, relationshipEntries)
const result: RelationshipEntryGroup[] = []
const seen = new Set<string>()
const addPlaceholder = (key: string) => {
const canonicalKey = canonicalFrontmatterKey(key)
if (canonicalKey === 'type' || existingKeys.has(canonicalKey) || seen.has(canonicalKey)) return
seen.add(canonicalKey)
result.push({ key, refs: [] })
}
for (const [key, refs] of Object.entries(typeEntry.relationships ?? {})) {
if (refs.length > 0) addPlaceholder(key)
}
for (const key of Object.keys(typeEntry.properties ?? {})) {
if (isRelationshipSchemaKey(key)) addPlaceholder(key)
}
return result
}
function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
@@ -602,6 +661,7 @@ function useMissingSuggestedRelationships(
}
function useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -609,21 +669,30 @@ function useRelationshipPanelState({
onUpdateProperty,
onDeleteProperty,
}: {
entry?: VaultEntry
frontmatter: ParsedFrontmatter
entries: VaultEntry[]
vaultPath?: string
} & RelationshipPanelEditHandlers) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const typeDerivedRelationshipEntries = useMemo(
() => buildTypeDerivedRelationshipEntries({ entry, entries, frontmatter, relationshipEntries }),
[entry, entries, frontmatter, relationshipEntries],
)
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const { handleRemoveRef, handleAddRef, canEdit } = useRelationshipMutations(relationshipEntries, {
onAddProperty,
onUpdateProperty,
onDeleteProperty,
})
const missingSuggestedRels = useMissingSuggestedRelationships(relationshipEntries, onAddProperty)
const missingSuggestedRels = useMissingSuggestedRelationships(
[...relationshipEntries, ...typeDerivedRelationshipEntries],
onAddProperty,
)
return {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
@@ -717,16 +786,17 @@ function DisabledLinkButton({ locale }: { locale: AppLocale }) {
)
}
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote, dataTestId = 'suggested-relationship' }: {
label: string
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
dataTestId?: string
}) {
return (
<RelationshipSectionRow label={label} dataTestId="suggested-relationship" locale={locale}>
<RelationshipSectionRow label={label} dataTestId={dataTestId} locale={locale} placeholder>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
@@ -738,8 +808,8 @@ function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, o
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
export function DynamicRelationshipsPanel({ entry, frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
entry?: VaultEntry; frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
@@ -749,12 +819,14 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
}) {
const {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -774,6 +846,18 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
locale={locale}
/>
))}
{onAddProperty && typeDerivedRelationshipEntries.map(({ key }) => (
<SuggestedRelationshipSlot
key={`type-derived:${key}`}
label={key}
entries={entries}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty(key, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
dataTestId="type-derived-relationship"
/>
))}
{missingSuggestedRels.map(label => (
<SuggestedRelationshipSlot
key={label}

View File

@@ -12,6 +12,7 @@ import {
resolveNewNote,
resolveNewType,
resolveTemplate,
resolveTypeInstanceDefaults,
DEFAULT_TEMPLATES,
RAPID_CREATE_NOTE_SETTLE_MS,
planNewNoteCreation,
@@ -171,6 +172,33 @@ describe('resolveNewNote', () => {
expect(content).not.toContain('status:')
})
it('applies valued properties and relationships from the type entry to newly created instances', () => {
const typeEntry = makeEntry({
title: 'Book',
isA: 'Type',
properties: {
Rating: 5,
'start date': null,
},
relationships: {
Author: ['[[person/frank-herbert]]'],
},
})
const defaults = resolveTypeInstanceDefaults({ entries: [typeEntry], typeName: 'Book' })
const { entry, content } = resolveNewNote({
title: 'Dune',
type: 'Book',
vaultPath: '/vault',
defaults,
})
expect(content).toContain('Rating: 5')
expect(content).toContain('Author: "[[person/frank-herbert]]"')
expect(content).not.toContain('start date:')
expect(entry.properties).toEqual({ Rating: 5 })
expect(entry.relationships).toEqual({ Author: ['[[person/frank-herbert]]'] })
})
it('blocks creation when macOS /tmp aliases point at the same note path', () => {
const plan = planNewNoteCreation({
entries: [makeEntry({ path: '/private/tmp/tolaria-vault/briefing.md', filename: 'briefing.md' })],

View File

@@ -7,6 +7,7 @@ import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity'
import { canonicalFrontmatterKey } from '../utils/systemMetadata'
export interface NewEntryParams {
path: string
@@ -88,6 +89,15 @@ export interface NoteContentParams {
status: string | null
template?: string | null
initialEmptyHeading?: boolean
defaults?: TypeInstanceDefault[]
}
type DefaultValue = string | number | boolean | string[]
export interface TypeInstanceDefault {
key: string
value: DefaultValue
kind: 'property' | 'relationship'
}
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
@@ -97,11 +107,103 @@ function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams
return template ? `\n${template}` : ''
}
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false }: NoteContentParams): string {
function isDefaultablePropertyValue(value: unknown): value is string | number | boolean {
if (typeof value === 'string') return value.trim().length > 0
return typeof value === 'number' || typeof value === 'boolean'
}
function relationshipDefaultValue(refs: string[]): DefaultValue | null {
if (refs.length === 0) return null
return refs.length === 1 ? refs[0] : refs
}
function resolveTypeEntry({ entries, typeName }: TemplateLookupParams): VaultEntry | undefined {
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
}
function collectPropertyDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
return Object.entries(typeEntry.properties ?? {}).flatMap(([key, value]) => (
isDefaultablePropertyValue(value) ? [{ key, value, kind: 'property' as const }] : []
))
}
function collectRelationshipDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
return Object.entries(typeEntry.relationships ?? {}).flatMap(([key, refs]) => {
const value = relationshipDefaultValue(refs)
return value ? [{ key, value, kind: 'relationship' as const }] : []
})
}
function appendUniqueDefault(defaults: TypeInstanceDefault[], seenKeys: Set<string>, defaultValue: TypeInstanceDefault) {
const canonicalKey = canonicalFrontmatterKey(defaultValue.key)
if (canonicalKey === 'type' || canonicalKey === 'title' || seenKeys.has(canonicalKey)) return
seenKeys.add(canonicalKey)
defaults.push(defaultValue)
}
export function resolveTypeInstanceDefaults(params: TemplateLookupParams): TypeInstanceDefault[] {
const typeEntry = resolveTypeEntry(params)
if (!typeEntry) return []
const defaults: TypeInstanceDefault[] = []
const seenKeys = new Set<string>()
const candidateDefaults = [
...collectPropertyDefaults(typeEntry),
...collectRelationshipDefaults(typeEntry),
]
candidateDefaults.forEach((defaultValue) => appendUniqueDefault(defaults, seenKeys, defaultValue))
return defaults
}
function hasOuterWhitespace(value: string): boolean {
return value.trim() !== value
}
function isYamlWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
function isAmbiguousYamlScalar(value: string): boolean {
return /^(?:true|false|null|[-+]?\d+(?:\.\d+)?)$/i.test(value)
}
function shouldQuoteYamlString(value: string): boolean {
return [
hasOuterWhitespace,
isYamlWikilink,
isAmbiguousYamlScalar,
(candidate: string) => candidate.includes(':'),
].some((check) => check(value))
}
function formatYamlScalar(value: string | number | boolean): string {
if (typeof value !== 'string') return String(value)
if (shouldQuoteYamlString(value)) return JSON.stringify(value)
return value
}
function appendDefaultFrontmatterLines(lines: string[], defaults: TypeInstanceDefault[]) {
const existingKeys = new Set(lines.map((line) => canonicalFrontmatterKey(line.split(':', 1)[0])))
for (const { key, value } of defaults) {
const canonicalKey = canonicalFrontmatterKey(key)
if (existingKeys.has(canonicalKey)) continue
existingKeys.add(canonicalKey)
if (Array.isArray(value)) {
lines.push(`${key}:`)
value.forEach((item) => lines.push(` - ${formatYamlScalar(item)}`))
} else {
lines.push(`${key}: ${formatYamlScalar(value)}`)
}
}
}
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false, defaults = [] }: NoteContentParams): string {
const lines = ['---']
if (title) lines.push(`title: ${title}`)
lines.push(`type: ${type}`)
if (status) lines.push(`status: ${status}`)
appendDefaultFrontmatterLines(lines, defaults)
lines.push('---')
const body = buildNoteBody({ template, initialEmptyHeading })
return `${lines.join('\n')}\n${body}`
@@ -112,13 +214,18 @@ export interface NewNoteParams {
type: string
vaultPath: string
template?: string | null
defaults?: TypeInstanceDefault[]
}
export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } {
export function resolveNewNote({ title, type, vaultPath, template, defaults = [] }: NewNoteParams): { entry: VaultEntry; content: string } {
const slug = slugify(title)
const status = null
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status })
return { entry, content: buildNoteContent({ title, type, status, template }) }
return applyTypeDefaults({
entry,
content: buildNoteContent({ title, type, status, template, defaults }),
defaults,
})
}
export interface NewTypeParams {
@@ -134,6 +241,37 @@ export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry:
type ResolvedEntry = { entry: VaultEntry; content: string }
function relationshipRefs(value: DefaultValue): string[] {
return Array.isArray(value) ? value : [String(value)]
}
function applyTypeDefaults({
entry,
content,
defaults,
}: {
entry: VaultEntry
content: string
defaults: TypeInstanceDefault[]
}): ResolvedEntry {
if (defaults.length === 0) return { entry, content }
const relationships = { ...entry.relationships }
const properties = { ...entry.properties }
for (const defaultValue of defaults) {
if (defaultValue.kind === 'relationship') {
relationships[defaultValue.key] = relationshipRefs(defaultValue.value)
continue
}
properties[defaultValue.key] = defaultValue.value as string | number | boolean
}
return {
entry: { ...entry, relationships, properties },
content,
}
}
interface BlockedCreationPlan {
status: 'blocked'
message: string
@@ -175,8 +313,9 @@ export function planNewNoteCreation({
type,
vaultPath,
template,
defaults,
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
const resolved = resolveNewNote({ title, type, vaultPath, template })
const resolved = resolveNewNote({ title, type, vaultPath, template, defaults })
const collision = findPathCollision(entries, resolved.entry.path)
if (collision) {
return {
@@ -314,7 +453,8 @@ async function createNamedNote({
creationPath,
}: NoteCreationRequest): Promise<boolean> {
const template = resolveTemplate({ entries, typeName: type })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template })
const defaults = resolveTypeInstanceDefaults({ entries, typeName: type })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template, defaults })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
@@ -468,16 +608,21 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
const defaults = resolveTypeInstanceDefaults({ entries: deps.entries, typeName: noteType })
const status = null
const entry = buildNewEntry({ path: joinVaultPath(deps.vaultPath, `${slug}.md`), slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
const resolved = applyTypeDefaults({
entry,
content: buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true, defaults }),
defaults,
})
const didPersist = await persistImmediateEntry(deps, resolved.entry, resolved.content)
if (!didPersist) return false
cacheNoteContent(entry.path, content, entry)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
signalFocusEditor({ path: entry.path, selectTitle: true })
cacheNoteContent(resolved.entry.path, resolved.content, resolved.entry)
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
signalFocusEditor({ path: resolved.entry.path, selectTitle: true })
return true
}

View File

@@ -122,6 +122,43 @@ describe('usePropertyPanelState', () => {
])
})
it('derives missing instance property placeholders from its type entry', () => {
const entries = [
makeEntry({
title: 'Book',
isA: 'Type',
properties: {
'start date': null,
Rating: 5,
},
}),
makeEntry({
title: 'Dune',
isA: 'Book',
properties: {
Rating: 4,
},
}),
]
const { result } = renderHook(() =>
usePropertyPanelState({
entries,
entryIsA: 'Book',
frontmatter: {
Rating: 4,
},
}),
)
expect(result.current.propertyEntries).toEqual([
['Rating', 4],
])
expect(result.current.typeDerivedPropertyEntries).toEqual([
['start date', null],
])
})
it('saves scalar and list properties through the correct handlers', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()

View File

@@ -10,11 +10,14 @@ import {
removeDisplayModeOverride,
} from '../utils/propertyTypes'
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
import { canonicalFrontmatterKey, canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
// Compared case-insensitively via isVisibleProperty()
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_archived', 'archived', 'archived_at', '_favorite', '_favorite_index', '_organized'])
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type PropertyEntry = [string, FrontmatterValue]
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
@@ -88,8 +91,8 @@ function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !isHiddenPropertyKey(key) && !containsWikilinks(value)
}
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, FrontmatterValue][] {
const result: [string, FrontmatterValue][] = []
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): PropertyEntry[] {
const result: PropertyEntry[] = []
const seen = new Set<string>()
for (const [key, value] of Object.entries(frontmatter)) {
@@ -104,6 +107,46 @@ function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, F
return result
}
function findTypeEntry(entries: VaultEntry[] | undefined, entryIsA: string | null): VaultEntry | undefined {
if (!entryIsA || entryIsA === 'Type') return undefined
return entries?.find((entry) => entry.isA === 'Type' && entry.title === entryIsA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingFrontmatterKeys(frontmatter: ParsedFrontmatter): Set<string> {
return new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
}
function buildTypeDerivedPropertyEntries({
entries,
entryIsA,
frontmatter,
}: {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
}): PropertyEntry[] {
const typeEntry = findTypeEntry(entries, entryIsA)
if (!typeEntry) return []
const existingKeys = buildExistingFrontmatterKeys(frontmatter)
const result: PropertyEntry[] = []
const seen = new Set<string>()
for (const [key, value] of Object.entries(typeEntry.properties ?? {})) {
const canonicalKey = canonicalFrontmatterKey(key)
if (existingKeys.has(canonicalKey) || seen.has(canonicalKey) || isRelationshipSchemaKey(key)) continue
if (!isVisibleProperty([key, value])) continue
seen.add(canonicalKey)
result.push([key, value])
}
return result
}
function addTagValues(tagsByKey: Map<string, Set<string>>, key: string, value: unknown) {
if (!Array.isArray(value)) return
@@ -197,6 +240,10 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
const propertyEntries = useMemo(() => buildVisiblePropertyEntries(frontmatter), [frontmatter])
const typeDerivedPropertyEntries = useMemo(
() => buildTypeDerivedPropertyEntries({ entries, entryIsA, frontmatter }),
[entries, entryIsA, frontmatter],
)
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
@@ -204,6 +251,12 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveTypeDerivedValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (!onUpdateProperty || newValue.trim() === '') return
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
@@ -226,7 +279,7 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries, typeDerivedPropertyEntries,
handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}

View File

@@ -0,0 +1,77 @@
import { test, expect, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
let tempVaultDir: string
function writeFixtureNote(vaultPath: string, filename: string, content: string): string {
const notePath = path.join(vaultPath, filename)
fs.writeFileSync(notePath, content, 'utf8')
return notePath
}
async function openNoteViaQuickOpen(page: Page, query: string): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.keyboard.press('Enter')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(new RegExp(query, 'i'), { timeout: 5_000 })
}
test.describe('Type-derived instance properties', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
writeFixtureNote(
tempVaultDir,
'book.md',
'---\ntype: Type\nstart date:\nRating: 5\nMentor: [[person/alice]]\n---\n# Book\n',
)
writeFixtureNote(
tempVaultDir,
'dune.md',
'---\ntype: Book\n---\n# Dune\n\nExisting instance without type schema fields.\n',
)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('type schema placeholders stay visible and valued defaults seed new instances @smoke', async ({ page }) => {
const existingBookPath = path.join(tempVaultDir, 'dune.md')
await openNoteViaQuickOpen(page, 'Dune')
await sendShortcut(page, 'i', ['Control', 'Shift'])
const startDatePlaceholder = page.getByTestId('type-derived-property').filter({ hasText: 'Start date' })
await expect(startDatePlaceholder).toBeVisible()
await expect(startDatePlaceholder.getByText('Start date')).toHaveClass(/text-muted-foreground\/40/)
const mentorPlaceholder = page.getByTestId('type-derived-relationship').filter({ hasText: 'Mentor' })
await expect(mentorPlaceholder).toBeVisible()
await expect(mentorPlaceholder.getByText('Mentor')).toHaveClass(/text-muted-foreground\/40/)
await startDatePlaceholder.click()
const startDateRow = page.getByTestId('editable-property').filter({ hasText: 'Start date' })
await startDateRow.locator('input').fill('2026-05-04')
await startDateRow.locator('input').blur()
await expect.poll(() => fs.readFileSync(existingBookPath, 'utf8')).toContain('start date: "2026-05-04"')
await page.locator('aside').getByText('Books', { exact: true }).first().click()
await page.locator('[title="Create new note"]').first().click()
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-book-\d+/i, { timeout: 5_000 })
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
const rawEditor = page.locator('.cm-content')
await expect(rawEditor).toContainText('type: Book')
await expect(rawEditor).toContainText('Rating: 5')
await expect(rawEditor).toContainText('Mentor: "[[person/alice]]"')
await expect(rawEditor).not.toContainText('start date:')
})
})

View File

@@ -59,18 +59,51 @@ function extractWikiLinks(value: string): string[] {
/** Extract wiki-links from a frontmatter value (string or array of strings). */
function wikiLinksFromValue(value: unknown): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (Array.isArray(value)) {
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
}
return []
return collectWikiLinksFromValue(value, 0)
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
function collectWikiLinksFromValue(value: unknown, depth: number): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (!Array.isArray(value)) return []
const nestedLink = nestedFlowWikilink(value, depth)
if (nestedLink) return [nestedLink]
return value.flatMap((item) => collectWikiLinksFromValue(item, depth + 1))
}
function nestedFlowWikilink(value: unknown[], depth: number): string | null {
if (depth === 0 || value.length !== 1 || typeof value[0] !== 'string') return null
return extractWikiLinks(value[0]).length === 0 ? `[[${value[0]}]]` : null
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic properties/relationships)
const DEDICATED_KEYS = new Set([
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
'related_to', 'related to', 'status', 'title',
])
'aliases', 'is_a', 'is a', 'type', 'status', 'title', '_archived',
'archived', '_icon', 'icon', 'color', '_order', 'order',
'_sidebar_label', 'sidebar_label', 'sidebar label', 'template',
'_sort', 'sort', 'view', '_width', 'width', 'visible',
'_organized', '_favorite', '_favorite_index', '_list_properties_display',
].map((key) => key.toLowerCase()))
type FrontmatterPropertyValue = string | number | boolean | null
type VaultSearchResult = { title: string; path: string; snippet: string; score: number; note_type: string | null }
interface SearchEntryInput {
entry: VaultEntry
query: string
rawContent: string
}
interface SearchRequestInput {
query: string
vaultPath: string
}
interface SearchResponseInput {
mode: string
query: string
results: VaultSearchResult[]
}
function getFrontmatterValue(
frontmatter: Record<string, unknown>,
@@ -211,6 +244,38 @@ function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<
return relationships
}
function frontmatterProperties(frontmatter: Record<string, unknown>): Record<string, FrontmatterPropertyValue> {
const properties: Record<string, FrontmatterPropertyValue> = {}
for (const [key, value] of Object.entries(frontmatter)) {
if (DEDICATED_KEYS.has(key.toLowerCase()) || key.trim().startsWith('_')) continue
const propertyValue = frontmatterPropertyValue(value)
if (propertyValue !== undefined) properties[key] = propertyValue
}
return properties
}
function isScalarFrontmatterProperty(value: unknown): value is number | boolean {
return typeof value === 'number' || typeof value === 'boolean'
}
function singleStringArrayValue(value: unknown): string | undefined {
if (!Array.isArray(value)) return undefined
if (value.length !== 1) return undefined
return typeof value[0] === 'string' ? value[0] : undefined
}
function wikiLinkFreeString(value: string): string | undefined {
return extractWikiLinks(value).length === 0 ? value : undefined
}
function frontmatterPropertyValue(value: unknown): FrontmatterPropertyValue | undefined {
if (value === null) return null
if (isScalarFrontmatterProperty(value)) return value
if (typeof value === 'string') return wikiLinkFreeString(value)
const singleArrayValue = singleStringArrayValue(value)
return singleArrayValue === undefined ? undefined : wikiLinkFreeString(singleArrayValue)
}
function parseMarkdownFile(filePath: string): VaultEntry | null {
try {
const raw = readUtf8File(filePath)
@@ -252,7 +317,7 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
view: frontmatterString(fm, 'view'),
visible: frontmatterBool(fm, 'visible'),
outgoingLinks: [],
properties: {},
properties: frontmatterProperties(fm),
}
} catch {
return null
@@ -390,21 +455,39 @@ function handleVaultSearch(url: URL, res: ServerResponse): boolean {
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
sendJson(res, { results: [], elapsed_ms: 0, query, mode })
sendVaultSearchResponse(res, { results: [], query, mode })
return true
}
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
sendVaultSearchResponse(res, {
results: collectVaultSearchResults({ vaultPath, query }),
query,
mode,
})
return true
}
function collectVaultSearchResults({ vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
const results: VaultSearchResult[] = []
for (const filePath of findMarkdownFiles(vaultPath)) {
const entry = parseMarkdownFile(filePath)
if (!entry || entry.trashed) continue
const raw = readUtf8File(filePath)
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
const rawContent = readUtf8File(filePath)
if (entryMatchesSearch({ entry, rawContent, query })) results.push(searchResultFromEntry(entry))
}
sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode })
return true
return results.slice(0, 20)
}
function entryMatchesSearch({ entry, rawContent, query }: SearchEntryInput): boolean {
return entry.title.toLowerCase().includes(query) || rawContent.toLowerCase().includes(query)
}
function searchResultFromEntry(entry: VaultEntry): VaultSearchResult {
return { title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }
}
function sendVaultSearchResponse(res: ServerResponse, { results, query, mode }: SearchResponseInput): void {
sendJson(res, { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode })
}
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {