From 3d6e237c5ef56c9cd949406e8fb765a30b6f213a Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Tue, 3 Mar 2026 01:37:35 +0100 Subject: [PATCH] fix: show all direct relationship properties in note list sidebar (#182) The GroupBuilder's `seen` set was causing direct relationship properties to be suppressed. Reverse/computed groups (Children, Events) ran before the entity's own relationship keys, consuming entries into `seen` and preventing direct properties like "Belongs to" and "Notes" from appearing. Fix: process all direct relationship keys from entity.relationships before the reverse groups, so direct properties always take priority. Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- src/utils/noteListHelpers.test.ts | 173 +++++++++++++++++++++++++++++- src/utils/noteListHelpers.ts | 13 ++- 2 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index f276a9f0..17e9d613 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers' +import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups } from './noteListHelpers' import type { VaultEntry } from '../types' function makeEntry(overrides: Partial = {}): VaultEntry { @@ -154,3 +154,174 @@ describe('relativeDate', () => { expect(relativeDate(1700000000)).toMatch(/Nov 14/) }) }) + +// --- buildRelationshipGroups tests --- + +function makeVault(overrides: Partial[]): VaultEntry[] { + return overrides.map((o, i) => makeEntry({ + path: `/Laputa/note/entry-${i}.md`, + filename: `entry-${i}.md`, + title: `Entry ${i}`, + modifiedAt: 1700000000 - i * 100, + ...o, + })) +} + +describe('buildRelationshipGroups', () => { + it('shows direct relationship properties from entity.relationships', () => { + const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' }) + const entity = makeEntry({ + path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', + relationships: { 'Belongs to': ['[[responsibility/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') + }) + + it('shows all direct relationships even when entries also appear as Children', () => { + // The entity has "Notes" pointing at note1 and note2. + // Those notes also have belongsTo pointing back at the entity. + // Previously, Children consumed them via the seen set, suppressing "Notes". + const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) + const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) + const entity = makeEntry({ + path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', + relationships: { Notes: ['[[note/note1]]', '[[note/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) + }) + + it('shows all 5+ direct relationship properties', () => { + const entries = makeVault([ + { path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' }, + { path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' }, + { path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' }, + { path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' }, + { path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' }, + { path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' }, + ]) + const entity = makeEntry({ + path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project', + relationships: { + 'Belongs to': ['[[area/eng]]'], + Notes: ['[[note/n1]]', '[[note/n2]]'], + Owner: ['[[person/alice]]'], + 'Related to': ['[[project/sibling]]'], + Topics: ['[[topic/rust]]'], + }, + }) + const groups = buildRelationshipGroups(entity, [entity, ...entries], {}) + const labels = groups.map((g) => g.label) + expect(labels).toContain('Belongs to') + expect(labels).toContain('Notes') + expect(labels).toContain('Owner') + expect(labels).toContain('Related to') + expect(labels).toContain('Topics') + }) + + it('shows Children group for reverse belongsTo entries not covered by direct rels', () => { + const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 }) + const entity = makeEntry({ + path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', + relationships: {}, + }) + 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') + }) + + it('excludes Type key from relationship groups', () => { + const entity = makeEntry({ + path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', + relationships: { Type: ['[[type/project]]'] }, + }) + 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], {}) + expect(groups).toHaveLength(0) + }) + + it('shows single-item and multi-item relationship properties', () => { + const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' }) + const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' }) + const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' }) + const entity = makeEntry({ + path: '/Laputa/project/x.md', filename: 'x.md', title: 'X', + relationships: { + Owner: ['[[person/alice]]'], + Notes: ['[[note/n1]]', '[[note/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) + }) + + it('shows Instances group for Type entities', () => { + const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 }) + const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 }) + const typeEntity = makeEntry({ + path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project', + isA: 'Type', relationships: {}, + }) + 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) + }) + + it('direct relationships are sorted alphabetically', () => { + const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' }) + const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' }) + const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' }) + const entity = makeEntry({ + path: '/Laputa/project/x.md', filename: 'x.md', title: 'X', + relationships: { + Zebra: ['[[note/c]]'], + Alpha: ['[[note/a]]'], + Middle: ['[[note/b]]'], + }, + }) + 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')) + }) + + it('Referenced By shows entries whose relatedTo matches the entity', () => { + const referer = makeEntry({ + path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer', + relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000, + }) + const entity = makeEntry({ + path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha', + relationships: {}, + }) + 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', () => { + const linker = makeEntry({ + path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000, + }) + 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) + expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker') + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 68b559ae..c4e84a2d 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -220,17 +220,16 @@ export function buildRelationshipGroups( b.filterAndAdd('Instances', (e) => e.isA === entity.title) } - b.addFromRefs('Has', rels['Has'] ?? []) - 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.addFromRefs('Topics', rels['Topics'] ?? []) - - const handledKeys = new Set(['Has', 'Topics']) + // Direct relationships first — all keys from entity.relationships take + // priority so that reverse/computed groups (Children, Events, Referenced By) + // only show *additional* entries not already covered by a direct property. Object.keys(rels) - .filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type') + .filter((k) => k.toLowerCase() !== 'type') .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)) b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))