test: add Playwright smoke test for exact-match search ranking

Verifies that searching "Writing" in Quick Open shows the exact title
match first, followed by prefix matches. Also adds Refactoring test
entries to mock data and demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-08 22:15:08 +01:00
parent 55e479be13
commit 3e05a9425a
37 changed files with 269 additions and 657 deletions

View File

@@ -38,25 +38,23 @@ describe('buildSystemPrompt', () => {
})
it('returns empty prompt for no notes', () => {
const result = buildSystemPrompt([], {})
const result = buildSystemPrompt([])
expect(result.prompt).toBe('')
expect(result.totalTokens).toBe(0)
expect(result.truncated).toBe(false)
})
it('includes note content in the prompt', () => {
it('includes note metadata in the prompt', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': '# Test Note\nHello world' }
const result = buildSystemPrompt(notes, content)
const result = buildSystemPrompt(notes)
expect(result.prompt).toContain('Test Note')
expect(result.prompt).toContain('Hello world')
expect(result.prompt).toContain('/test.md')
expect(result.totalTokens).toBeGreaterThan(0)
})
it('instructs AI to use wikilink syntax', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': 'content' }
const result = buildSystemPrompt(notes, content)
const result = buildSystemPrompt(notes)
expect(result.prompt).toContain('[[')
expect(result.prompt).toMatch(/wikilink/i)
})

View File

@@ -21,47 +21,31 @@ export function getContextLimit(): number {
// --- Context building ---
/** Build system prompt from selected context notes. */
/** Build system prompt from selected context notes (metadata only — content loaded via MCP). */
export function buildSystemPrompt(
notes: VaultEntry[],
allContent: Record<string, string>,
): { prompt: string; totalTokens: number; truncated: boolean } {
if (notes.length === 0) {
return { prompt: '', totalTokens: 0, truncated: false }
}
const contextBudget = Math.floor(getContextLimit() * 0.6)
const preamble = [
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
'The user has selected the following notes as context. Use them to answer questions accurately.',
'You can use MCP tools to read the full content of any note.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
'',
].join('\n')
const parts: string[] = [preamble]
let totalChars = preamble.length
let truncated = false
for (const note of notes) {
const content = allContent[note.path] ?? ''
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---`
const noteText = `${header}\n${content}\n`
if (estimateTokens(totalChars + noteText.length) > contextBudget) {
const remaining = (contextBudget - estimateTokens(totalChars)) * 4
if (remaining > 200) {
parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`)
}
truncated = true
break
}
parts.push(noteText)
totalChars += noteText.length
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) | Path: ${note.path} ---`
parts.push(header)
}
const prompt = parts.join('\n')
return { prompt, totalTokens: estimateTokens(prompt), truncated }
return { prompt, totalTokens: estimateTokens(prompt), truncated: false }
}
// --- Message types ---

View File

@@ -133,53 +133,25 @@ describe('collectLinkedEntries', () => {
})
describe('buildContextualPrompt', () => {
it('includes active note title and content', () => {
it('includes active note title and type', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' })
const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' }
const prompt = buildContextualPrompt(active, [], content)
const prompt = buildContextualPrompt(active, [])
expect(prompt).toContain('Alpha')
expect(prompt).toContain('Project')
expect(prompt).toContain('This is the alpha project.')
})
it('includes linked note content', () => {
it('includes linked note titles', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
const content = {
'/vault/a.md': '# Alpha\nMain note.',
'/vault/b.md': '# Beta\nLinked person.',
}
const prompt = buildContextualPrompt(active, [linked], content)
const prompt = buildContextualPrompt(active, [linked])
expect(prompt).toContain('Beta')
expect(prompt).toContain('Person')
expect(prompt).toContain('Linked person.')
expect(prompt).toContain('Linked Notes')
})
it('shows (no content) when content is missing', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], {})
expect(prompt).toContain('(no content)')
})
it('truncates linked note content to 2000 chars', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' })
const longContent = 'x'.repeat(3000)
const content = {
'/vault/a.md': '# Alpha',
'/vault/b.md': longContent,
}
const prompt = buildContextualPrompt(active, [linked], content)
// The linked note content should be truncated
const betaIdx = prompt.indexOf('### Beta')
const afterBeta = prompt.slice(betaIdx)
expect(afterBeta.length).toBeLessThan(2200)
})
it('includes the system preamble', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' })
const prompt = buildContextualPrompt(active, [])
expect(prompt).toContain('AI assistant integrated into Laputa')
})
})
@@ -191,14 +163,9 @@ describe('buildContextSnapshot', () => {
makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }),
makeEntry({ path: '/vault/c.md', title: 'Gamma', isA: 'Note' }),
]
const allContent: Record<string, string> = {
'/vault/a.md': '# Alpha\nProject content.',
'/vault/b.md': '# Beta\nPerson content.',
'/vault/c.md': '# Gamma\nNote content.',
}
it('includes activeNote with body and frontmatter', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const result = buildContextSnapshot({ activeEntry: active, entries, activeNoteContent: '---\ntitle: Alpha\n---\n# Alpha\nProject content.' })
expect(result).toContain('Alpha')
expect(result).toContain('Project content.')
expect(result).toContain('"type": "Project"')
@@ -207,13 +174,13 @@ describe('buildContextSnapshot', () => {
})
it('includes system preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const result = buildContextSnapshot({ activeEntry: active, entries })
expect(result).toContain('AI assistant integrated into Laputa')
expect(result).toContain('Context Snapshot')
})
it('includes vault summary with types and totalNotes', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.vault.totalNotes).toBe(3)
expect(json.vault.types).toContain('Project')
@@ -224,7 +191,7 @@ describe('buildContextSnapshot', () => {
it('includes openTabs excluding active note', () => {
const tab = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
openTabs: [active, tab],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -234,7 +201,7 @@ describe('buildContextSnapshot', () => {
it('omits openTabs when none besides active', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
openTabs: [active],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -243,7 +210,7 @@ describe('buildContextSnapshot', () => {
it('includes noteListFilter when present', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
noteListFilter: { type: 'Project', query: 'search' },
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -253,16 +220,16 @@ describe('buildContextSnapshot', () => {
it('omits noteListFilter when empty', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
noteListFilter: { type: null, query: '' },
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.noteListFilter).toBeUndefined()
})
it('includes referencedNotes with bodies', () => {
it('includes referencedNotes metadata', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
references: [
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
{ title: 'Gamma', path: '/vault/c.md', type: null },
@@ -271,25 +238,11 @@ describe('buildContextSnapshot', () => {
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toHaveLength(2)
expect(json.referencedNotes[0].title).toBe('Beta')
expect(json.referencedNotes[0].body).toContain('Person content.')
expect(json.referencedNotes[1].type).toBe('Note') // null fallback
})
it('filters out references with no content', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
references: [
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
{ title: 'Missing', path: '/vault/missing.md', type: null },
],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toHaveLength(1)
expect(json.referencedNotes[0].title).toBe('Beta')
})
it('omits referencedNotes when no references provided', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.referencedNotes).toBeUndefined()
})
@@ -300,7 +253,7 @@ describe('buildContextSnapshot', () => {
{ path: '/vault/b.md', title: 'Beta', type: 'Person' },
]
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -314,7 +267,7 @@ describe('buildContextSnapshot', () => {
path: `/vault/note-${i}.md`, title: `Note ${i}`, type: 'Note',
}))
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
noteList,
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -324,7 +277,7 @@ describe('buildContextSnapshot', () => {
it('omits noteList when empty', () => {
const result = buildContextSnapshot({
activeEntry: active, allContent, entries,
activeEntry: active, entries,
noteList: [],
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
@@ -332,10 +285,8 @@ describe('buildContextSnapshot', () => {
})
it('strips frontmatter from activeNoteContent before setting body', () => {
const emptyAllContent: Record<string, string> = {}
const result = buildContextSnapshot({
activeEntry: active,
allContent: emptyAllContent,
entries,
activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.',
})
@@ -348,7 +299,6 @@ describe('buildContextSnapshot', () => {
it('returns empty body when raw content is frontmatter-only (bug case)', () => {
const result = buildContextSnapshot({
activeEntry: active,
allContent: {},
entries,
activeNoteContent: '---\ntitle: Alpha\nis_a: Project\nstatus: active\n---\n',
})
@@ -356,10 +306,9 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('')
})
it('prefers activeNoteContent body over allContent when both present', () => {
it('uses activeNoteContent for body', () => {
const result = buildContextSnapshot({
activeEntry: active,
allContent,
entries,
activeNoteContent: '---\ntitle: Alpha\n---\nFresh editor content',
})
@@ -367,15 +316,15 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('Fresh editor content')
})
it('falls back to allContent when activeNoteContent is undefined', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
it('returns empty body when no activeNoteContent', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toBe('# Alpha\nProject content.')
expect(json.activeNote.body).toBe('')
})
it('includes wordCount in activeNote', () => {
const entryWithWords = makeEntry({ path: '/vault/a.md', title: 'Alpha', wordCount: 206 })
const result = buildContextSnapshot({ activeEntry: entryWithWords, allContent, entries })
const result = buildContextSnapshot({ activeEntry: entryWithWords, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.wordCount).toBe(206)
})
@@ -383,7 +332,6 @@ describe('buildContextSnapshot', () => {
it('handles content with no frontmatter (plain markdown)', () => {
const result = buildContextSnapshot({
activeEntry: active,
allContent: {},
entries,
activeNoteContent: '# Just a heading\n\nSome plain content.',
})
@@ -391,29 +339,12 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('# Just a heading\n\nSome plain content.')
})
it('falls back to allContent when activeNoteContent is empty string (|| fix)', () => {
// Regression: `??` does not fall through on empty string '', only on null/undefined.
// The `||` fix ensures empty string falls through to allContent.
const result = buildContextSnapshot({
activeEntry: active,
allContent,
entries,
activeNoteContent: '',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toContain('Project content.')
expect(json.activeNote.body).not.toBe('')
})
it('includes defensive body when body is empty but wordCount > 0', () => {
// When body is empty (e.g. timing issue) but note has content on disk,
// body field should instruct Claude to use get_note
const entryWithWords = makeEntry({
path: '/vault/a.md', title: 'Alpha', wordCount: 206,
})
const result = buildContextSnapshot({
activeEntry: entryWithWords,
allContent: {},
entries,
activeNoteContent: '---\ntitle: Alpha\n---\n',
})
@@ -423,7 +354,7 @@ describe('buildContextSnapshot', () => {
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const result = buildContextSnapshot({ activeEntry: active, entries })
expect(result).toContain('[[Note Title]]')
expect(result).toContain('wikilink')
})
@@ -435,7 +366,7 @@ describe('buildContextSnapshot', () => {
relatedTo: ['[[Sibling]]'],
relationships: { people: ['[[Alice]]'] },
})
const result = buildContextSnapshot({ activeEntry: entryWithRels, allContent, entries })
const result = buildContextSnapshot({ activeEntry: entryWithRels, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.frontmatter.belongsTo).toEqual(['[[Parent]]'])
expect(json.activeNote.frontmatter.relatedTo).toEqual(['[[Sibling]]'])

View File

@@ -78,7 +78,6 @@ export interface NoteListItem {
/** Parameters for building the structured context snapshot. */
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
/** Direct content of the active note from the editor tab (most reliable source). */
activeNoteContent?: string
openTabs?: VaultEntry[]
@@ -103,12 +102,9 @@ const MAX_NOTE_LIST_ITEMS = 100
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
// Use `||` (not `??`) so empty string '' falls through to allContent.
// This handles the case where handleEditorChange temporarily overwrites
// tab.content with frontmatter-only content during async content swaps.
const rawContent = activeNoteContent || allContent[activeEntry.path] || ''
const rawContent = activeNoteContent || ''
let body = extractBody(rawContent)
// Defence-in-depth: when body is empty but the note has content on disk,
@@ -161,14 +157,11 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
}
if (references && references.length > 0) {
snapshot.referencedNotes = references
.filter(ref => allContent[ref.path] !== undefined)
.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
body: extractBody(allContent[ref.path] ?? ''),
}))
snapshot.referencedNotes = references.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
}))
}
const preamble = [
@@ -186,7 +179,6 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
export function buildContextualPrompt(
active: VaultEntry,
linkedEntries: VaultEntry[],
allContent: Record<string, string>,
): string {
const parts: string[] = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
@@ -195,18 +187,14 @@ export function buildContextualPrompt(
'',
`## Active Note: ${active.title}`,
`Type: ${active.isA ?? 'Note'} | Path: ${active.path}`,
'',
allContent[active.path] ?? '(no content)',
]
if (linkedEntries.length > 0) {
parts.push('', '## Linked Notes')
for (const entry of linkedEntries) {
const content = allContent[entry.path]
parts.push(
'',
`### ${entry.title} (${entry.isA ?? 'Note'})`,
content ? content.slice(0, 2000) : '(no content loaded)',
)
}
}

View File

@@ -22,7 +22,6 @@ describe('flushEditorContent', () => {
savePendingForPath: vi.fn().mockResolvedValue(false),
getTabContent: vi.fn().mockReturnValue(undefined),
isUnsaved: vi.fn().mockReturnValue(false),
getSavedContent: vi.fn().mockReturnValue(undefined),
onSaved: vi.fn(),
}
})
@@ -51,9 +50,9 @@ describe('flushEditorContent', () => {
expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', '# New note content')
})
it('saves tab content when it differs from saved content (dirty editor)', async () => {
it('saves tab content when note is marked unsaved (dirty editor)', async () => {
;(deps.getTabContent as ReturnType<typeof vi.fn>).mockReturnValue('edited body')
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('original body')
;(deps.isUnsaved as ReturnType<typeof vi.fn>).mockReturnValue(true)
await flushEditorContent('/vault/note.md', deps)
@@ -64,9 +63,9 @@ describe('flushEditorContent', () => {
expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', 'edited body')
})
it('does not save when tab content matches saved content (no changes)', async () => {
it('does not save when note is not unsaved (clean editor)', async () => {
;(deps.getTabContent as ReturnType<typeof vi.fn>).mockReturnValue('same content')
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('same content')
;(deps.isUnsaved as ReturnType<typeof vi.fn>).mockReturnValue(false)
await flushEditorContent('/vault/note.md', deps)

View File

@@ -4,7 +4,6 @@ export interface FlushDeps {
savePendingForPath: (path: string) => Promise<boolean>
getTabContent: (path: string) => string | undefined
isUnsaved: (path: string) => boolean
getSavedContent: (path: string) => string | undefined
onSaved?: (path: string, content: string) => void
}
@@ -22,7 +21,7 @@ export async function flushEditorContent(path: string, deps: FlushDeps): Promise
const tabContent = deps.getTabContent(path)
if (tabContent === undefined) return
if (deps.isUnsaved(path) || tabContent !== deps.getSavedContent(path)) {
if (deps.isUnsaved(path)) {
await persistContent(path, tabContent)
deps.onSaved?.(path, tabContent)
}

View File

@@ -1,61 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mergeTabContent } from './mergeTabContent'
describe('mergeTabContent', () => {
it('adds tab content when allContent is empty', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' },
])
expect(result['/vault/a.md']).toBe('# Hello\nBody text')
})
it('overrides allContent with tab content (editor state is fresher)', () => {
const result = mergeTabContent(
{ '/vault/a.md': 'old saved content' },
[{ entry: { path: '/vault/a.md' }, content: 'current editor content' }],
)
expect(result['/vault/a.md']).toBe('current editor content')
})
it('preserves allContent for paths without open tabs', () => {
const result = mergeTabContent(
{ '/vault/b.md': 'other note content' },
[{ entry: { path: '/vault/a.md' }, content: '# Hello' }],
)
expect(result['/vault/b.md']).toBe('other note content')
expect(result['/vault/a.md']).toBe('# Hello')
})
it('returns original object when no tabs have content to merge', () => {
const original = { '/vault/a.md': 'content' }
expect(mergeTabContent(original, [])).toBe(original)
})
it('skips tabs with empty content', () => {
const original = { '/vault/a.md': 'content' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/b.md' }, content: '' },
])
expect(result).toBe(original)
expect(result['/vault/b.md']).toBeUndefined()
})
it('handles multiple tabs', () => {
const result = mergeTabContent({}, [
{ entry: { path: '/vault/a.md' }, content: 'Note A' },
{ entry: { path: '/vault/b.md' }, content: 'Note B' },
])
expect(result['/vault/a.md']).toBe('Note A')
expect(result['/vault/b.md']).toBe('Note B')
})
it('does not mutate the original allContent object', () => {
const original = { '/vault/a.md': 'old' }
const result = mergeTabContent(original, [
{ entry: { path: '/vault/a.md' }, content: 'new' },
])
expect(original['/vault/a.md']).toBe('old')
expect(result['/vault/a.md']).toBe('new')
expect(result).not.toBe(original)
})
})

View File

@@ -1,18 +0,0 @@
/**
* Merge open tab content into the allContent dictionary.
* Tab content takes priority (it reflects the current editor state).
* Returns the original object if no changes are needed (stable reference for useMemo).
*/
export function mergeTabContent(
allContent: Record<string, string>,
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
): Record<string, string> {
let merged: Record<string, string> | null = null
for (const tab of tabs) {
if (!tab.content) continue
if (allContent[tab.entry.path] === tab.content) continue
if (!merged) merged = { ...allContent }
merged[tab.entry.path] = tab.content
}
return merged ?? allContent
}

View File

@@ -174,7 +174,7 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, building], {})
const groups = buildRelationshipGroups(entity, [entity, building])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
@@ -190,7 +190,7 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
const groups = buildRelationshipGroups(entity, [entity, note1, note2])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Notes')
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
@@ -215,7 +215,7 @@ describe('buildRelationshipGroups', () => {
Topics: ['[[topic/rust]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
const groups = buildRelationshipGroups(entity, [entity, ...entries])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(labels).toContain('Notes')
@@ -230,7 +230,7 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child], {})
const groups = buildRelationshipGroups(entity, [entity, child])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Children')
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
@@ -241,14 +241,14 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Type: ['[[type/project]]'] },
})
const groups = buildRelationshipGroups(entity, [entity], {})
const groups = buildRelationshipGroups(entity, [entity])
const labels = groups.map((g) => g.label)
expect(labels).not.toContain('Type')
})
it('returns empty groups for entity with no relationships', () => {
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
const groups = buildRelationshipGroups(entity, [entity], {})
const groups = buildRelationshipGroups(entity, [entity])
expect(groups).toHaveLength(0)
})
@@ -263,7 +263,7 @@ describe('buildRelationshipGroups', () => {
Notes: ['[[note/n1]]', '[[note/n2]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2])
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
@@ -275,7 +275,7 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
isA: 'Type', relationships: {},
})
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2])
const labels = groups.map((g) => g.label)
expect(labels).toContain('Instances')
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
@@ -293,7 +293,7 @@ describe('buildRelationshipGroups', () => {
Middle: ['[[note/b]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
const groups = buildRelationshipGroups(entity, [entity, a, b, c])
const directLabels = groups.map((g) => g.label)
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
@@ -308,20 +308,20 @@ describe('buildRelationshipGroups', () => {
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, referer], {})
const groups = buildRelationshipGroups(entity, [entity, referer])
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
})
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
it('Backlinks shows entries that mention the entity via outgoingLinks', () => {
const linker = makeEntry({
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
outgoingLinks: ['Alpha'],
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
const groups = buildRelationshipGroups(entity, [entity, linker])
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
})

View File

@@ -242,21 +242,16 @@ export function clearListSortFromLocalStorage(): void {
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[]): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const targets = [entity.title, ...entity.aliases]
const targets = new Set([entity.title, ...entity.aliases, stem, pathStem])
return allEntries.filter((e) => {
if (e.path === entity.path) return false
const content = allContent[e.path]
if (!content) return false
for (const t of targets) {
if (content.includes(`[[${t}]]`)) return true
}
if (content.includes(`[[${stem}]]`)) return true
if (content.includes(`[[${pathStem}]]`)) return true
return content.includes(`[[${pathStem}|`)
return e.outgoingLinks.some((link) =>
targets.has(link) || targets.has(link.split('/').pop() ?? ''),
)
})
}
@@ -292,7 +287,6 @@ class GroupBuilder {
export function buildRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
allContent: Record<string, string>,
): RelationshipGroup[] {
const b = new GroupBuilder(entity.path, allEntries)
const rels = entity.relationships ?? {}
@@ -312,7 +306,7 @@ export function buildRelationshipGroups(
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified))
return b.groups
}