diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 07847b82..f314c21a 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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]]`. diff --git a/package.json b/package.json index b4fa0467..b5b2596f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index ab4e98fa..d761848d 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -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 = 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 { + 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, +) { + 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 { + 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, @@ -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()); } diff --git a/src-tauri/src/vault/frontmatter_regression_tests.rs b/src-tauri/src/vault/frontmatter_regression_tests.rs index dd760591..cf41361d 100644 --- a/src-tauri/src/vault/frontmatter_regression_tests.rs +++ b/src-tauri/src/vault/frontmatter_regression_tests.rs @@ -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 = [ diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index df403c39..124725e9 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -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 diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 623d6429..ff36f461 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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 ( + + ) + } + + const PlaceholderIcon = DISPLAY_MODE_ICONS[displayMode] + + return ( + + ) +} + 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()) @@ -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 + vaultStatuses: string[] + vaultTagsByKey: Record + 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' ? ( + + ) : ( + + ) + ))} + + ) +} + +function PendingSuggestedPropertyRow({ + pendingSuggestedKey, + editingKey, + vaultStatuses, + vaultTagsByKey, + locale, + onStartEdit, + onSave, + onSaveList, + onDisplayModeChange, +}: { + pendingSuggestedKey: string | null + editingKey: string | null + vaultStatuses: string[] + vaultTagsByKey: Record + 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 ( + + ) +} + +function SuggestedPropertyRows({ + properties, + onAdd, +}: { + properties: Array<{ key: string; label: string }> + onAdd: (key: string) => void +}) { + return ( + <> + {properties.map(({ key, label }) => ( + 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(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]) => ( - - ))} - {pendingSuggestedKey && editingKey === pendingSuggestedKey && ( - - )} - {missingSuggested.map(({ key, label }) => ( - handleSuggestedAdd(key)} - /> - ))} + + + + {!showAddDialog && ( { 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( - - {humanizePropertyKey(label)} + + {humanizePropertyKey(label)}
{children}
@@ -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 { + 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() + + 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 locale: AppLocale + dataTestId?: string }) { return ( - + ; 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; 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 }) => ( + onAddProperty(key, ref)} + onCreateAndOpenNote={onCreateAndOpenNote} + locale={locale} + dataTestId="type-derived-relationship" + /> + ))} {missingSuggestedRels.map(label => ( { 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' })], diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 20587bca..c6aadaf1 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -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): string { @@ -97,11 +107,103 @@ function buildNoteBody({ template, initialEmptyHeading }: Pick 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, 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() + 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 { 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 } diff --git a/src/hooks/usePropertyPanelState.test.ts b/src/hooks/usePropertyPanelState.test.ts index ac7e062c..6b8c63b0 100644 --- a/src/hooks/usePropertyPanelState.test.ts +++ b/src/hooks/usePropertyPanelState.test.ts @@ -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() diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index b775f166..16725318 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -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() 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 { + 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() + + 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>, 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, } } diff --git a/tests/smoke/type-derived-properties.spec.ts b/tests/smoke/type-derived-properties.spec.ts new file mode 100644 index 00000000..43099117 --- /dev/null +++ b/tests/smoke/type-derived-properties.spec.ts @@ -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 { + 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:') + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 14fcd2ae..b733a0e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, @@ -211,6 +244,38 @@ function frontmatterRelationships(frontmatter: Record): Record< return relationships } +function frontmatterProperties(frontmatter: Record): Record { + const properties: Record = {} + 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 {