diff --git a/src/components/NoteList.rendering.test.tsx b/src/components/NoteList.rendering.test.tsx index 165b54ef..d0b90253 100644 --- a/src/components/NoteList.rendering.test.tsx +++ b/src/components/NoteList.rendering.test.tsx @@ -379,6 +379,48 @@ describe('NoteList rendering', () => { expect(screen.getAllByText('Shared Note')).toHaveLength(2) }) + it('shows all real inverse relationship groups for custom relationship keys', () => { + const parent = makeEntry({ + path: '/vault/parent.md', + filename: 'parent.md', + title: 'Parent', + isA: 'Project', + }) + const topicNote = makeEntry({ + path: '/vault/topic-note.md', + filename: 'topic-note.md', + title: 'Topic Note', + isA: 'Note', + relationships: { Topics: ['[[parent]]'] }, + }) + const mentorNote = makeEntry({ + path: '/vault/mentor-note.md', + filename: 'mentor-note.md', + title: 'Mentor Note', + isA: 'Note', + relationships: { Mentors: ['[[parent]]'] }, + }) + const hostEvent = makeEntry({ + path: '/vault/host-event.md', + filename: 'host-event.md', + title: 'Host Event', + isA: 'Event', + relationships: { Hosts: ['[[parent]]'] }, + }) + + renderNoteList({ + entries: [parent, topicNote, mentorNote, hostEvent], + selection: { kind: 'entity', entry: parent }, + }) + + expect(screen.getByText('← Topics')).toBeInTheDocument() + expect(screen.getByText('← Mentors')).toBeInTheDocument() + expect(screen.getByText('← Hosts')).toBeInTheDocument() + expect(screen.getByText('Topic Note')).toBeInTheDocument() + expect(screen.getByText('Mentor Note')).toBeInTheDocument() + expect(screen.getByText('Host Event')).toBeInTheDocument() + }) + it('collapses and expands entity groups', () => { renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } }) expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index b5b627f9..aad98127 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -164,6 +164,42 @@ describe('buildRelationshipGroups', () => { expect(groups.find((group) => group.label === 'Referenced By')?.entries).toEqual([shared]) }) + it('includes all inverse relationship groups for non-core relationship keys', () => { + const parent = makeEntry({ + path: '/vault/parent.md', + filename: 'parent.md', + title: 'Parent', + isA: 'Project', + }) + const topicNote = makeEntry({ + path: '/vault/topic-note.md', + filename: 'topic-note.md', + title: 'Topic Note', + isA: 'Note', + relationships: { Topics: ['[[parent]]'] }, + }) + const mentorNote = makeEntry({ + path: '/vault/mentor-note.md', + filename: 'mentor-note.md', + title: 'Mentor Note', + isA: 'Note', + relationships: { Mentors: ['[[parent]]'] }, + }) + const hostEvent = makeEntry({ + path: '/vault/host-event.md', + filename: 'host-event.md', + title: 'Host Event', + isA: 'Event', + relationships: { Hosts: ['[[parent]]'] }, + }) + + const groups = buildRelationshipGroups(parent, [parent, topicNote, mentorNote, hostEvent]) + + expect(groups.find((group) => group.label === '← Topics')?.entries).toEqual([topicNote]) + expect(groups.find((group) => group.label === '← Mentors')?.entries).toEqual([mentorNote]) + expect(groups.find((group) => group.label === '← Hosts')?.entries).toEqual([hostEvent]) + }) + it('does not treat path-qualified refs for a different note as reverse matches', () => { const parent = makeEntry({ path: '/vault/projects/alpha.md', diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index be95de6a..ec238d2f 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -294,6 +294,81 @@ class GroupBuilder { } } +function appendInverseRelationshipEntries( + inverseGroups: Map, + label: string, + entry: VaultEntry, +) { + const existing = inverseGroups.get(label) + if (existing) { + existing.push(entry) + return + } + + inverseGroups.set(label, [entry]) +} + +function resolveInverseRelationshipLabel(key: string, entry: VaultEntry): string { + if (key === 'Belongs to') return entry.isA === 'Event' ? 'Events' : 'Children' + if (key === 'Related to') return entry.isA === 'Event' ? 'Events' : 'Referenced By' + return `← ${key}` +} + +function appendLegacyInverseRelationshipEntries( + inverseGroups: Map, + entity: VaultEntry, + entry: VaultEntry, +) { + if (refsMatch(entry.belongsTo, entity)) { + appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Belongs to', entry), entry) + } + + if (refsMatch(entry.relatedTo, entity)) { + appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Related to', entry), entry) + } +} + +function appendDynamicInverseRelationshipEntries( + inverseGroups: Map, + entity: VaultEntry, + entry: VaultEntry, +) { + for (const [key, refs] of Object.entries(entry.relationships ?? {})) { + if (key === 'Type' || !refsMatch(refs, entity)) continue + appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel(key, entry), entry) + } +} + +function orderInverseRelationshipLabels(inverseGroups: Map): string[] { + const preferredOrder = ['Children', 'Events', 'Referenced By'] + const customLabels = [...inverseGroups.keys()] + .filter((label) => !preferredOrder.includes(label)) + .sort((a, b) => a.localeCompare(b)) + + return [...preferredOrder, ...customLabels] +} + +function collectInverseRelationshipGroups( + entity: VaultEntry, + allEntries: VaultEntry[], +): RelationshipGroup[] { + const inverseGroups = new Map() + + for (const other of allEntries) { + if (other.path === entity.path) continue + appendLegacyInverseRelationshipEntries(inverseGroups, entity, other) + appendDynamicInverseRelationshipEntries(inverseGroups, entity, other) + } + + return orderInverseRelationshipLabels(inverseGroups) + .map((label) => { + const entries = inverseGroups.get(label) + if (!entries) return null + return { label, entries: [...entries].sort(sortByModified) } + }) + .filter((group): group is RelationshipGroup => group !== null) +} + export function buildRelationshipGroups( entity: VaultEntry, allEntries: VaultEntry[], @@ -313,9 +388,7 @@ export function buildRelationshipGroups( .sort((a, b) => a.localeCompare(b)) .forEach((key) => b.addFromRefs(key, rels[key] ?? [])) - 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)) + collectInverseRelationshipGroups(entity, allEntries).forEach((group) => b.add(group.label, group.entries)) b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified)) return b.groups