File size: 4,104 Bytes
1749a26
 
 
 
 
 
 
 
 
 
 
 
 
 
6ac1f78
 
 
1749a26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ac1f78
 
1749a26
 
 
 
 
 
 
 
6ac1f78
1749a26
 
 
 
 
 
 
 
 
d016f87
6ac1f78
1749a26
 
 
6ac1f78
1749a26
6ac1f78
 
 
 
 
1749a26
 
 
 
6ac1f78
 
1749a26
 
 
 
 
 
6ac1f78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1749a26
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// citations.js — split paragraph text into plain-text and in-text citation segments.
//
// splitCitations(text, citeIndex, references)
//   text       — raw paragraph string
//   citeIndex  — { 'surname:year': [refId, ...] }
//   references — array of Reference objects from the parse result
//
// Returns segments:
//   { t: 'text', s: '...' }
//   { t: 'cite', s: '...', refs: [{ id, corpusMatch }] }
//
// Recognizes:
//   Narrative:      Surname et al. (YEAR) / Surname & Other (YEAR) / Surname (YEAR)
//   Parenthetical:  (Surname et al. YEAR; Surname YEAR)
//
// Parenthetical blocks are expanded: each individual cite unit inside becomes
// its own 'cite' segment so that clicking Noeske vs Elbaz opens different papers.

const _NARRATIVE_RE = /\b([A-Z][a-zA-ZÀ-ÿ\-']+)(?:\s+et\s+al\.?|\s+&\s+[A-Z][a-zA-ZÀ-ÿ\-']+)?\s+\(((19|20)\d{2}[a-z]?)\)/g;
const _PAREN_RE = /\(([A-Z][a-zA-ZÀ-ÿ\-']+(?:\s+et\s+al\.?|\s+&\s+[A-Z][a-zA-ZÀ-ÿ\-']+)?\s+(?:19|20)\d{2}[a-z]?(?:\s*;\s*[A-Z][a-zA-ZÀ-ÿ\-']+(?:\s+et\s+al\.?|\s+&\s+[A-Z][a-zA-ZÀ-ÿ\-']+)?\s+(?:19|20)\d{2}[a-z]?)*)\)/g;
const _CITE_UNIT = /([A-Z][a-zA-ZÀ-ÿ\-']+)(?:\s+et\s+al\.?|\s+&\s+[A-Z][a-zA-ZÀ-ÿ\-']+)?\s+((19|20)\d{2}[a-z]?)/g;

function _lookupRefs(surname, year, citeIndex, refsById) {
  const key = `${surname.toLowerCase()}:${year}`;
  const ids = citeIndex[key] || [];
  return ids.map((id) => refsById[id]).filter(Boolean).map((r) => ({
    id: r.id,
    corpusMatch: r.corpusMatch,
    bibcode: r.bibcode,
    arxiv: r.arxiv,
  }));
}

export function splitCitations(text, citeIndex, references) {
  if (!text) return [];
  const refsById = Object.fromEntries((references || []).map((r) => [r.id, r]));

  // Collect all matches (narrative and parenthetical), sorted by position.
  // Paren matches carry a `units` array so the segment builder can expand them.
  const matches = [];

  _NARRATIVE_RE.lastIndex = 0;
  let m;
  while ((m = _NARRATIVE_RE.exec(text)) !== null) {
    const surname = m[1];
    const year = m[2];
    const refs = _lookupRefs(surname, year, citeIndex, refsById);
    matches.push({ start: m.index, end: m.index + m[0].length, s: m[0], refs });
  }

  _PAREN_RE.lastIndex = 0;
  while ((m = _PAREN_RE.exec(text)) !== null) {
    // Skip if already covered by a narrative match
    const covered = matches.some((mx) => mx.start <= m.index && m.index < mx.end);
    if (covered) continue;

    const inner = m[1];
    _CITE_UNIT.lastIndex = 0; // must reset — module-level /g regex retains state between calls
    const units = [];
    let unit;
    while ((unit = _CITE_UNIT.exec(inner)) !== null) {
      const refs = _lookupRefs(unit[1], unit[2], citeIndex, refsById);
      units.push({ s: unit[0], refs, iStart: unit.index, iEnd: unit.index + unit[0].length });
    }
    matches.push({
      start: m.index, end: m.index + m[0].length, s: m[0],
      refs: units.flatMap((u) => u.refs), // kept for compatibility
      inner, units,
    });
  }

  matches.sort((a, b) => a.start - b.start);

  // Build segments. Parenthetical matches are expanded into individual cite spans
  // separated by their original punctuation (; etc.) so each is independently clickable.
  const segments = [];
  let lastIndex = 0;
  for (const mx of matches) {
    if (mx.start > lastIndex) {
      segments.push({ t: 'text', s: text.slice(lastIndex, mx.start) });
    }
    if (mx.units) {
      segments.push({ t: 'text', s: '(' });
      let innerLast = 0;
      for (const u of mx.units) {
        if (u.iStart > innerLast) {
          segments.push({ t: 'text', s: mx.inner.slice(innerLast, u.iStart) });
        }
        segments.push({ t: 'cite', s: u.s, refs: u.refs });
        innerLast = u.iEnd;
      }
      if (innerLast < mx.inner.length) {
        segments.push({ t: 'text', s: mx.inner.slice(innerLast) });
      }
      segments.push({ t: 'text', s: ')' });
    } else {
      segments.push({ t: 'cite', s: mx.s, refs: mx.refs });
    }
    lastIndex = mx.end;
  }
  if (lastIndex < text.length) {
    segments.push({ t: 'text', s: text.slice(lastIndex) });
  }
  return segments;
}