mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix(extract): link-aware Source — Summary delimiter in timeline bullets (#3059)
Takeover of #3060: the Format-1 timeline parser split 'Source — Summary' at the first dash after the pipe, which lands inside markdown links (hyphenated link targets, em-dash link labels), shattering one entry into two fragments that re-insert on every sync. Delimiter scan is now bracket-depth-aware and whitespace-anchored; delimiterless bullets are kept whole under the 'markdown' source sentinel instead of dropped. Test fixtures renamed to the repo's generic placeholders. Co-authored-by: wright-io <wright-io@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
wright-io
Claude Fable 5
parent
e79b8d5780
commit
393805ab2c
+40
-2
@@ -469,15 +469,53 @@ export async function extractLinksFromFile(
|
||||
|
||||
// --- Timeline extraction ---
|
||||
|
||||
/**
|
||||
* Index of the first dash (—, –, -) that can serve as the Source — Summary
|
||||
* delimiter: it must have whitespace on both sides and sit outside every
|
||||
* markdown-link span. Hyphens inside link targets
|
||||
* (`../people/alice-example.md`) and dashes inside link labels
|
||||
* (`[Deals — Q1 Review](...)`) are content, not delimiters — splitting on
|
||||
* them shatters one entry into two fragments whose halves re-insert on
|
||||
* every sync (the (page_id, date, summary, source) uniqueness sees each
|
||||
* fragment shape as a new row). Returns -1 when the line has no delimiter.
|
||||
*/
|
||||
function findDelimiterOutsideLinks(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (c === '[' || c === '(') depth++;
|
||||
else if (c === ']' || c === ')') { if (depth > 0) depth--; }
|
||||
else if (
|
||||
depth === 0 &&
|
||||
(c === '—' || c === '–' || c === '-') &&
|
||||
i > 0 && /\s/.test(text[i - 1]) &&
|
||||
i + 1 < text.length && /\s/.test(text[i + 1])
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Extract timeline entries from markdown content */
|
||||
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
|
||||
const entries: ExtractedTimelineEntry[] = [];
|
||||
|
||||
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
|
||||
// The delimiter search is link-aware (see findDelimiterOutsideLinks); a
|
||||
// bullet with no delimiter (e.g. an auto-generated backlink line
|
||||
// `- **date** | Referenced in [X](y.md)`) is kept whole as the summary
|
||||
// rather than dropped or fragmented.
|
||||
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm;
|
||||
let match;
|
||||
while ((match = bulletPattern.exec(content)) !== null) {
|
||||
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
|
||||
const rest = match[2].trim();
|
||||
const at = findDelimiterOutsideLinks(rest);
|
||||
if (at >= 0) {
|
||||
entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() });
|
||||
} else {
|
||||
entries.push({ slug, date: match[1], source: 'markdown', summary: rest });
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: Header — ### YYYY-MM-DD — Title
|
||||
|
||||
@@ -136,6 +136,38 @@ describe('extractTimelineFromContent', () => {
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not split on hyphens inside markdown link targets', () => {
|
||||
const content = `- **2025-03-18** | Referenced in [Alice](../people/alice-example.md)`;
|
||||
const entries = extractTimelineFromContent(content, 'companies/acme-example');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Referenced in [Alice](../people/alice-example.md)');
|
||||
});
|
||||
|
||||
it('does not split on spaced dashes inside link labels', () => {
|
||||
const content = `- **2025-03-18** | Referenced in [Deals — Q1 Review](../deals/q1-review.md)`;
|
||||
const entries = extractTimelineFromContent(content, 'companies/acme-example');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Referenced in [Deals — Q1 Review](../deals/q1-review.md)');
|
||||
});
|
||||
|
||||
it('splits on the first spaced dash outside links', () => {
|
||||
const content = `- **2025-03-18** | [Board notes](../meetings/2025-03-18-board.md) — Approved the hire`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('[Board notes](../meetings/2025-03-18-board.md)');
|
||||
expect(entries[0].summary).toBe('Approved the hire');
|
||||
});
|
||||
|
||||
it('keeps delimiterless bullet lines whole instead of dropping them', () => {
|
||||
const content = `- **2025-03-18** | Imported from legacy tracker`;
|
||||
const entries = extractTimelineFromContent(content, 'test');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('markdown');
|
||||
expect(entries[0].summary).toBe('Imported from legacy tracker');
|
||||
});
|
||||
|
||||
it('extracts inline citation format entries', () => {
|
||||
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
|
||||
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
|
||||
|
||||
Reference in New Issue
Block a user