mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(backlinks): dedupe (source, target) pairs within a single source page
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 <p3ob7o@users.noreply.github.com>
This commit is contained in:
@@ -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<string>();
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user