Compare commits

...

10 Commits

Author SHA1 Message Date
Test
12f8fad0f0 fix: align suggested property slots with real properties in Inspector
Add text-left to the suggested property button (browsers default buttons
to text-align: center) and remove text-right from the em-dash placeholder
so both label and value columns match the real property row alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:27:08 +02:00
Test
c90b1d6694 fix: add bottom padding to Favorites list matching top padding
Added paddingBottom: 4 to the expanded Favorites list container,
consistent with the ViewsSection pattern. This creates symmetric
spacing above the first and below the last favorite item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:09:29 +02:00
Test
806b552d47 fix: default sidebar section to Inbox on app launch and vault switch
Changed DEFAULT_SELECTION from 'all' (All Notes) to 'inbox' so the
app always opens on the Inbox section. During a session the user's
navigation is preserved; on next launch or vault switch it resets
to Inbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:44:18 +02:00
Test
2cd192fae6 style: apply rustfmt to classify_file_kind tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:19:44 +02:00
Test
46106da47a fix: pass forceRawMode through ActiveTabBreadcrumb to BreadcrumbBar
TypeScript error: forceRawMode was set in the props object but not
declared in the ActiveTabBreadcrumb type or forwarded to BreadcrumbBar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:15:11 +02:00
Test
0051559b8e fix: hide Raw toggle for non-markdown files forced into raw editor
Non-markdown text files (.yml, .yaml, .json, etc.) are already forced
into raw editor mode via effectiveRawMode. This hides the Raw toggle
button in the breadcrumb bar for those files since toggling is not
applicable. Adds tests for classify_file_kind and forceRawMode behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:59:19 +02:00
Test
3f19528cb6 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>
2026-04-05 12:29:14 +02:00
Test
24e5b537d6 fix: wikilink autocomplete in FilterBuilder no longer clipped by dialog
The dropdown was position:absolute inside a scrollable container with
overflow-y:auto, causing it to be clipped. Now uses createPortal to
render to document.body with position:fixed, escaping the dialog's
overflow. Also adds proper CSS for menu items (font-size 12px, aligned
icon+text, compact padding) via .wikilink-menu--filter modifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:58:08 +02:00
Test
f8f6e3d003 fix: always show note title in breadcrumb bar when raw/diff mode active
Use prop-driven data-title-hidden attribute in BreadcrumbBar when rawMode or
diffMode is true. This avoids the timing issue where the useEffect DOM mutation
fired after paint with a null ref, causing the title to never appear.

The IntersectionObserver-based scroll detection is preserved for normal editor
mode (title shows in breadcrumb when title section scrolls out of view).
2026-04-05 11:07:53 +02:00
Test
ba9b5c7c31 fix: make Select scroll buttons functional by using overflow-hidden on Content
The SelectContent had overflow-y-auto which caused the browser to handle
scrolling instead of Radix UI's internal scroll mechanism. This made the
SelectScrollDownButton (chevron ▼) non-functional. Changing to
overflow-hidden lets Radix's Viewport manage scrolling, making the
scroll buttons work as intended.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:07:02 +02:00
13 changed files with 230 additions and 40 deletions

View File

@@ -1521,6 +1521,43 @@ fn test_non_yml_file_uses_filename_as_title() {
assert_eq!(entry.title, "notes.txt");
}
#[test]
fn test_classify_file_kind_yml_is_text() {
assert_eq!(
classify_file_kind(Path::new("views/active-projects.yml")),
"text"
);
assert_eq!(classify_file_kind(Path::new("config.yaml")), "text");
assert_eq!(classify_file_kind(Path::new("data.json")), "text");
assert_eq!(classify_file_kind(Path::new("script.py")), "text");
assert_eq!(classify_file_kind(Path::new("readme.txt")), "text");
}
#[test]
fn test_classify_file_kind_md_is_markdown() {
assert_eq!(classify_file_kind(Path::new("note.md")), "markdown");
assert_eq!(classify_file_kind(Path::new("README.markdown")), "markdown");
}
#[test]
fn test_classify_file_kind_unknown_is_binary() {
assert_eq!(classify_file_kind(Path::new("photo.png")), "binary");
assert_eq!(classify_file_kind(Path::new("archive.zip")), "binary");
}
#[test]
fn test_non_md_file_gets_text_file_kind() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"views/my-view.yml",
"name: My View\nicon: rocket\n",
);
let entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
assert_eq!(entry.file_kind, "text");
assert_eq!(entry.title, "My View");
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -70,7 +70,7 @@ declare global {
}
}
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {

View File

@@ -174,4 +174,17 @@ describe('BreadcrumbBar — raw editor toggle', () => {
fireEvent.click(screen.getByTitle('Raw editor'))
expect(onToggleRaw).toHaveBeenCalledOnce()
})
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
})
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
const onToggleRaw = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
})
})

View File

@@ -26,6 +26,8 @@ interface BreadcrumbBarProps {
onToggleDiff: () => void
rawMode?: boolean
onToggleRaw?: () => void
/** When true, raw mode is forced (non-markdown file) — hide the toggle. */
forceRawMode?: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed?: boolean
@@ -58,7 +60,7 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
}
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
rawMode, onToggleRaw,
rawMode, onToggleRaw, forceRawMode,
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
onToggleFavorite, onToggleOrganized, onTrash, onRestore, onArchive, onUnarchive,
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
@@ -112,7 +114,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<GitBranch size={16} />
</button>
)}
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<button
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
style={DISABLED_ICON_STYLE}
@@ -204,10 +206,14 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, barRef, ...actionProps
}: BreadcrumbBarProps) {
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
return (
<div
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
className="breadcrumb-bar flex shrink-0 items-center"
style={{
height: 52,

View File

@@ -71,14 +71,14 @@ const SUGGESTED_PROPERTIES = ['Status', 'Date', 'URL'] as const
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
return (
<button
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
tabIndex={0}
onClick={onAdd}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
data-testid="suggested-property"
>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/50">{label}</span>
<span className="min-w-0 truncate text-right text-[12px] text-muted-foreground/30">{'\u2014'}</span>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
)
}

View File

@@ -127,7 +127,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
activeTab: Tab
barRef: React.RefObject<HTMLDivElement | null>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'> & { forceRawMode?: boolean }
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
@@ -142,6 +142,7 @@ function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
onToggleDiff={props.onToggleDiff}
rawMode={props.rawMode}
onToggleRaw={props.onToggleRaw}
forceRawMode={props.forceRawMode}
showAIChat={props.showAIChat}
onToggleAIChat={props.onToggleAIChat}
inspectorCollapsed={props.inspectorCollapsed}
@@ -182,19 +183,16 @@ export function EditorContent({
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
// When in normal editor mode: use IntersectionObserver to show/hide the breadcrumb title
// based on whether the title section has scrolled out of view.
// In raw/diff mode, BreadcrumbBar handles title visibility via its rawMode/diffMode props.
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
if (!bar) return
// In raw/diff mode the title section is not rendered, so there is nothing
// for the IntersectionObserver to watch. Force the title visible instead.
if (!showEditor) {
bar.setAttribute('data-title-hidden', '')
return () => { bar.removeAttribute('data-title-hidden') }
}
const el = titleSectionRef.current
if (!el) return
if (!bar || !el) return
const observer = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
@@ -225,7 +223,7 @@ export function EditorContent({
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, ...breadcrumbProps }}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText, ...breadcrumbProps }}
/>
{isTrashed && (
<TrashedNoteBanner

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

@@ -1,4 +1,5 @@
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
import { format, parseISO } from 'date-fns'
import { Button } from '@/components/ui/button'
@@ -111,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,
@@ -146,18 +149,30 @@ function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: (
}, [refs, onClose])
}
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: {
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>
}) {
return (
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null)
useEffect(() => {
const el = anchorRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setPos({ top: rect.bottom + 2, left: rect.left, width: rect.width })
}, [anchorRef, matches])
if (!pos) return null
return createPortal(
<div
className="wikilink-menu"
className="wikilink-menu wikilink-menu--filter"
ref={menuRef}
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width }}
data-testid="wikilink-dropdown"
>
{matches.map((item, index) => (
@@ -165,21 +180,22 @@ 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 }}>
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
{item.TypeIcon && <item.TypeIcon width={12} height={12} style={{ color: item.typeColor, flexShrink: 0 }} />}
{item.title}
</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 6px' }}>
{item.noteType}
</span>
)}
</div>
))}
</div>
</div>,
document.body,
)
}
@@ -203,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)
@@ -220,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()
@@ -241,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])
@@ -261,7 +279,7 @@ function WikilinkValueInput({ value, entries, onChange }: {
}, [onChange, resetIndex])
return (
<div style={{ position: 'relative' }} className="flex-1 min-w-0">
<div className="flex-1 min-w-0">
<Input
ref={inputRef}
className="h-8 w-full text-sm"
@@ -279,6 +297,7 @@ function WikilinkValueInput({ value, entries, onChange }: {
onSelect={handleSelect}
onHover={setSelectedIndex}
menuRef={menuRef}
anchorRef={inputRef}
/>
)}
</div>

View File

@@ -392,7 +392,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 4 }}>
{favorites.map((entry) => {
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
return (

View File

@@ -10,3 +10,49 @@
max-width: 400px;
z-index: 9999;
}
.wikilink-menu__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.wikilink-menu__item:hover,
.wikilink-menu__item--selected {
background: hsl(var(--accent));
}
.wikilink-menu__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.wikilink-menu__type {
flex-shrink: 0;
font-size: 10px;
line-height: 1.4;
white-space: nowrap;
}
/* Compact variant for FilterBuilder inside dialogs */
.wikilink-menu--filter {
font-size: 12px;
max-height: 240px;
overflow-y: auto;
}
.wikilink-menu--filter .wikilink-menu__item {
padding: 4px 8px;
font-size: 12px;
}
.wikilink-menu--filter .wikilink-menu__type {
font-size: 10px;
}

View File

@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className

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
}