From 2e4bef74779d02a4b0eba491758e3deb49f10014 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 18 May 2026 13:34:46 -0700 Subject: [PATCH] fix(backlinks): dedupe (source, target) pairs within a single source page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source page that mentions the same entity N times produced N duplicate "Referenced in" lines on the target. `extractEntityRefs` returns one EntityRef per occurrence, and the per-ref `hasBacklink` check reads a snapshot of `target.content` that's frozen at outer scope — so every iteration sees "no backlink yet" and appends another gap. The cumulative effect on a long meeting note with multiple mentions of the same person was visible in PRs landing 3-5 identical Timeline entries. Track seen target slugs per source page; cap gaps at one pair. Cherry-picked from PR #967 with a current-master regression test covering both markdown-link and Obsidian-wikilink formats in the same source page. Co-Authored-By: p3ob7o --- src/commands/backlinks.ts | 11 +++++++++++ test/backlinks.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/commands/backlinks.ts b/src/commands/backlinks.ts index a928c096a..bdde915c1 100644 --- a/src/commands/backlinks.ts +++ b/src/commands/backlinks.ts @@ -100,9 +100,20 @@ export function findBacklinkGaps(brainDir: string): BacklinkGap[] { for (const page of allPages) { const refs = extractEntityRefs(page.content, page.relPath); const sourceFilename = basename(page.relPath); + // LOCAL PATCH (paolo, 2026-05-12): dedupe (source, target) pairs within + // a single source page. extractEntityRefs returns one EntityRef per + // occurrence, so a source page that mentions the same target N times + // produced N identical gaps → N duplicate "Referenced in" lines on the + // target. The per-ref `hasBacklink(target.content, ...)` check reads a + // stale snapshot (target.content is frozen at this scope), so every + // iteration sees the same "no backlink yet" state and pushes another + // gap. Tracking seen target slugs per source caps gaps at one per pair. + const seen = new Set(); for (const ref of refs) { const targetSlug = `${ref.dir}/${ref.slug}`; + if (seen.has(targetSlug)) continue; + seen.add(targetSlug); const target = pagesBySlug.get(targetSlug); if (!target) continue; // target page doesn't exist diff --git a/test/backlinks.test.ts b/test/backlinks.test.ts index a01705af7..649af1ed3 100644 --- a/test/backlinks.test.ts +++ b/test/backlinks.test.ts @@ -78,3 +78,29 @@ describe('buildBacklinkEntry', () => { expect(entry).toBe('- **2026-04-11** | Referenced in [Q1 Review](../../meetings/q1-review.md)'); }); }); + +describe('findBacklinkGaps dedupe (v0.36.x #967 regression)', () => { + test('a source page mentioning the same target N times yields one gap, not N', async () => { + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('fs'); + const { tmpdir } = await import('os'); + const { join } = await import('path'); + const { findBacklinkGaps } = await import('../src/commands/backlinks.ts'); + + const root = mkdtempSync(join(tmpdir(), 'gbrain-backlinks-dedupe-')); + try { + mkdirSync(join(root, 'people')); + mkdirSync(join(root, 'meetings')); + writeFileSync(join(root, 'people/alice.md'), '# Alice'); + // Source page mentions alice three times, no Timeline yet on alice + writeFileSync( + join(root, 'meetings/standup.md'), + '# Standup\n\nWe discussed [Alice](people/alice).\nLater [Alice](people/alice) chimed in.\nFinally [[people/alice]] left.\n', + ); + const gaps = findBacklinkGaps(root); + const alicePairs = gaps.filter(g => g.targetPage === 'people/alice.md' && g.sourcePage === 'meetings/standup.md'); + expect(alicePairs.length).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});