From 3f19528cb6b9b0425152bcabebc841669c01095a Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 5 Apr 2026 12:29:14 +0200 Subject: [PATCH] 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) --- src/components/FilterBuilder.test.tsx | 4 +-- src/components/FilterBuilder.tsx | 16 +++++---- src/utils/viewFilters.test.ts | 52 +++++++++++++++++++++++++++ src/utils/viewFilters.ts | 29 ++++++++++++--- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx index c260909c..3203b569 100644 --- a/src/components/FilterBuilder.test.tsx +++ b/src/components/FilterBuilder.test.tsx @@ -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]]' }], }), ) }) diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index ff4cd66b..5b55882e 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -112,8 +112,10 @@ function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record 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[], 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 anchorRef: React.RefObject @@ -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)} > @@ -217,7 +219,7 @@ function useScrollSelectedIntoView(menuRef: React.RefObject 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]) diff --git a/src/utils/viewFilters.test.ts b/src/utils/viewFilters.test.ts index 39668eaa..114ab8cc 100644 --- a/src/utils/viewFilters.test.ts +++ b/src/utils/viewFilters.test.ts @@ -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, diff --git a/src/utils/viewFilters.ts b/src/utils/viewFilters.ts index c75896f3..c94e100c 100644 --- a/src/utils/viewFilters.ts +++ b/src/utils/viewFilters.ts @@ -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 }