Compare commits
11 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7443b9a468 | ||
|
|
12f8fad0f0 | ||
|
|
c90b1d6694 | ||
|
|
806b552d47 | ||
|
|
2cd192fae6 | ||
|
|
46106da47a | ||
|
|
0051559b8e | ||
|
|
3f19528cb6 | ||
|
|
24e5b537d6 | ||
|
|
f8f6e3d003 | ||
|
|
ba9b5c7c31 |
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
fireEvent.click(screen.getByTestId('submit-add-relationship'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
if (refs.length === 0) return null
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground">{label}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{refs.map((ref, idx) => {
|
||||
const props = resolveRefProps(ref, entries, typeEntryMap)
|
||||
@@ -368,7 +368,7 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
onSubmitWithCreate={handleCreateAndSubmit}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
|
||||
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">Add</button>
|
||||
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,32 +402,15 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={(noteTitle) => { onAdd(noteTitle); setActive(false) }}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="mb-2.5 flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-0 py-1 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(true)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActive(true) } }}
|
||||
data-testid="suggested-relationship"
|
||||
>
|
||||
<span className="font-mono-overline text-muted-foreground/50">{label}</span>
|
||||
<span className="text-[12px] text-muted-foreground/30">{'\u2014'}</span>
|
||||
</button>
|
||||
<div className="mb-2.5" data-testid="suggested-relationship">
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={onAdd}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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