fix: wikilink filter values now match frontmatter format with alias support

The FilterBuilder autocomplete now stores wikilinks as [[stem|title]]
(e.g., [[monday-112|Monday 112]]) instead of just [[title]]. The view
filter comparison uses wikilinkEquals() to match any combination of
path and alias parts, so [[monday-112|Monday 112]] matches
[[monday-112|Monday #112]] via the shared path "monday-112".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-05 12:29:14 +02:00
parent 24e5b537d6
commit 3f19528cb6
4 changed files with 88 additions and 13 deletions

View File

@@ -91,7 +91,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
})
it('inserts [[note-title]] when a note is selected', () => {
it('inserts [[stem|title]] when a note is selected', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
})
@@ -100,7 +100,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
fireEvent.click(screen.getByText('Alpha Project'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
all: [{ field: 'title', op: 'contains', value: '[[alpha|Alpha Project]]' }],
}),
)
})

View File

@@ -112,8 +112,10 @@ function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA || undefined
const stem = e.filename.replace(/\.md$/, '')
return {
title: e.title,
stem,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
@@ -150,7 +152,7 @@ function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: (
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef, anchorRef }: {
matches: WikilinkMatch[]
selectedIndex: number
onSelect: (title: string) => void
onSelect: (title: string, stem?: string) => void
onHover: (index: number) => void
menuRef: React.RefObject<HTMLDivElement | null>
anchorRef: React.RefObject<HTMLElement | null>
@@ -178,7 +180,7 @@ function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef,
key={item.title}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => onSelect(item.title)}
onClick={() => onSelect(item.title, item.stem)}
onMouseEnter={() => onHover(index)}
>
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -217,7 +219,7 @@ function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | nul
function useDropdownKeyboard(
matches: WikilinkMatch[],
open: boolean,
onSelect: (title: string) => void,
onSelect: (title: string, stem?: string) => void,
onClose: () => void,
) {
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -234,7 +236,8 @@ function useDropdownKeyboard(
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault()
onSelect(matches[selectedIndex].title)
const m = matches[selectedIndex]
onSelect(m.title, m.stem)
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
@@ -255,8 +258,9 @@ function WikilinkValueInput({ value, entries, onChange }: {
const matches = useWikilinkMatches(entries, value, open)
const handleSelect = useCallback((title: string) => {
onChange(`[[${title}]]`)
const handleSelect = useCallback((title: string, stem?: string) => {
const wikilink = stem && stem !== title ? `[[${stem}|${title}]]` : `[[${title}]]`
onChange(wikilink)
setOpen(false)
}, [onChange])

View File

@@ -289,6 +289,58 @@ describe('evaluateView', () => {
expect(result.map((e) => e.title)).toEqual(['Yes'])
})
it('wikilink filter matches frontmatter with alias via path', () => {
const view: ViewDefinition = {
name: 'By path', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[monday-112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[tuesday-200|Tuesday]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('wikilink filter matches frontmatter with alias via alias', () => {
const view: ViewDefinition = {
name: 'By alias', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[Monday #112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[tuesday-200|Tuesday]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('wikilink filter with stem|title format matches frontmatter path', () => {
const view: ViewDefinition = {
name: 'Stem format', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[monday-112|Monday 112]]' }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No match', relationships: { 'belongs to': ['[[other]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('any_of on relationship uses alias matching', () => {
const view: ViewDefinition = {
name: 'Any of', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'any_of', value: ['[[monday-112|Monday 112]]'] }] },
}
const entries = [
makeEntry({ title: 'Match', relationships: { 'belongs to': ['[[monday-112|Monday #112]]'] } }),
makeEntry({ title: 'No', relationships: { 'belongs to': ['[[other]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('body is_empty matches notes with empty snippet', () => {
const view: ViewDefinition = {
name: 'Empty body', icon: null, color: null, sort: null,

View File

@@ -51,6 +51,23 @@ function wikilinkStem(raw: string): string {
return s.toLowerCase()
}
/** Extract all comparable parts (path and alias) from a wikilink string. */
function wikilinkParts(raw: string): string[] {
let s = raw.trim()
if (s.startsWith('[[')) s = s.slice(2)
if (s.endsWith(']]')) s = s.slice(0, -2)
const pipe = s.indexOf('|')
if (pipe >= 0) return [s.substring(0, pipe).toLowerCase(), s.substring(pipe + 1).toLowerCase()]
return [s.toLowerCase()]
}
/** Check if two wikilink values match by comparing all path/alias combinations. */
function wikilinkEquals(a: string, b: string): boolean {
const partsA = wikilinkParts(a)
const partsB = wikilinkParts(b)
return partsA.some(pa => partsB.some(pb => pa === pb))
}
function toString(v: unknown): string {
if (v == null) return ''
if (typeof v === 'string') return v
@@ -78,17 +95,19 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
const stem = wikilinkStem(condVal)
const isWikilink = condVal.trim().startsWith('[[')
const arrayMatch = (arr: string[]) => arr.some((item) =>
isWikilink ? wikilinkStem(item) === stem : wikilinkStem(item).includes(stem)
isWikilink ? wikilinkEquals(item, condVal) : wikilinkStem(item).includes(stem)
)
if (op === 'contains') return arrayMatch(resolved.array)
if (op === 'not_contains') return !arrayMatch(resolved.array)
if (op === 'any_of' && Array.isArray(value)) {
const stems = (value as string[]).map(wikilinkStem)
return resolved.array.some((item) => stems.includes(wikilinkStem(item)))
return resolved.array.some((item) =>
(value as string[]).some((v) => wikilinkEquals(item, v))
)
}
if (op === 'none_of' && Array.isArray(value)) {
const stems = (value as string[]).map(wikilinkStem)
return !resolved.array.some((item) => stems.includes(wikilinkStem(item)))
return !resolved.array.some((item) =>
(value as string[]).some((v) => wikilinkEquals(item, v))
)
}
return false
}