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:
@@ -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]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user