|
| 1 | +import {JSONContent} from '@tiptap/core' |
| 2 | + |
| 3 | +/** |
| 4 | + * Google Docs "date me docs" are usually written with comments, and pasting one in brings the comments |
| 5 | + * along as a block of footnotes at the very bottom: each in-text marker (`[a]`, `[b]`, ...) is a link to |
| 6 | + * `#cmnt_refN`, and the comment text sits in a trailing paragraph that starts with the same marker. |
| 7 | + * |
| 8 | + * Neither end of that pairing survives the paste: tiptap's link mark keeps `href` but drops `id`, so every |
| 9 | + * `#cmnt_refN` anchor points at a target that no longer exists. Rather than try to preserve Google's ids, |
| 10 | + * we re-derive the pairing from the markers themselves — which also fixes the footnotes whose marker lost |
| 11 | + * its link on the way in. |
| 12 | + */ |
| 13 | + |
| 14 | +/** A marker as it stands alone in the text: `[a]`, `[12]`. Docs uses letters; other exports use numbers. */ |
| 15 | +const MARKER = /^\[([a-z]{1,3}|\d{1,3})\]$/i |
| 16 | + |
| 17 | +/** The same marker opening a trailing paragraph, followed by the footnote's text. */ |
| 18 | +const DEFINITION = /^\[([a-z]{1,3}|\d{1,3})\]\s*/i |
| 19 | + |
| 20 | +/** Google Docs' own anchor names, kept as aliases so old `#cmnt_ref2` links still land somewhere. */ |
| 21 | +const GOOGLE_ANCHOR = /^#(cmnt[a-z_]*\d+)$/i |
| 22 | + |
| 23 | +/** |
| 24 | + * How many unlabelled paragraphs may sit between two definitions before we assume we've walked out of the |
| 25 | + * footnote block and back into the bio. A long footnote can run to several paragraphs, but not many. |
| 26 | + */ |
| 27 | +const MAX_CONTINUATION_RUN = 8 |
| 28 | + |
| 29 | +export type Footnote = { |
| 30 | + /** lowercased marker, e.g. `b` */ |
| 31 | + label: string |
| 32 | + /** the comment body, marker stripped; paragraphs joined by blank lines */ |
| 33 | + text: string |
| 34 | + /** Google Docs anchor names found on this definition's marker, e.g. `cmnt_ref2` */ |
| 35 | + aliases: string[] |
| 36 | +} |
| 37 | + |
| 38 | +export type FootnoteIndex = { |
| 39 | + byLabel: Record<string, Footnote> |
| 40 | + /** top-level node index -> label of the definition starting there */ |
| 41 | + definitionAt: Map<number, string> |
| 42 | +} |
| 43 | + |
| 44 | +export const footnoteDefId = (label: string) => `fn-${label}` |
| 45 | +export const footnoteRefId = (label: string) => `fnref-${label}` |
| 46 | + |
| 47 | +/** The label this text refers to, if it is a bare marker for a known footnote. */ |
| 48 | +export const footnoteLabelOf = (text: string, index: FootnoteIndex): string | undefined => { |
| 49 | + const label = MARKER.exec(text.trim())?.[1]?.toLowerCase() |
| 50 | + return label && index.byLabel[label] ? label : undefined |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * The marker at the head of `label`'s definition, if this text starts with it. The paste sometimes merges |
| 55 | + * the marker into the same text run as the footnote body, so it isn't always a text node of its own. |
| 56 | + */ |
| 57 | +export const footnoteDefinitionPrefix = (text: string, label: string): string | undefined => |
| 58 | + text.toLowerCase().startsWith(`[${label}]`) ? text.slice(0, label.length + 2) : undefined |
| 59 | + |
| 60 | +/** True for the now-dangling `#cmnt…` anchors Google Docs leaves behind. */ |
| 61 | +export const isGoogleCommentAnchor = (href: string | undefined | null) => |
| 62 | + !!href && GOOGLE_ANCHOR.test(href.slice(href.indexOf('#'))) |
| 63 | + |
| 64 | +const textOf = (node: JSONContent): string => |
| 65 | + node.type === 'text' ? (node.text ?? '') : (node.content ?? []).map(textOf).join('') |
| 66 | + |
| 67 | +const forEachTextNode = (node: JSONContent, fn: (node: JSONContent) => void) => { |
| 68 | + if (node.type === 'text') fn(node) |
| 69 | + else (node.content ?? []).forEach((child) => forEachTextNode(child, fn)) |
| 70 | +} |
| 71 | + |
| 72 | +const aliasesOf = (node: JSONContent, label: string): string[] => { |
| 73 | + const aliases: string[] = [] |
| 74 | + forEachTextNode(node, (text) => { |
| 75 | + if ((text.text ?? '').trim().toLowerCase() !== `[${label}]`) return |
| 76 | + for (const mark of text.marks ?? []) { |
| 77 | + const href: string | undefined = mark.attrs?.href |
| 78 | + const alias = href && GOOGLE_ANCHOR.exec(href.slice(href.indexOf('#')))?.[1] |
| 79 | + if (alias) aliases.push(alias) |
| 80 | + } |
| 81 | + }) |
| 82 | + return aliases |
| 83 | +} |
| 84 | + |
| 85 | +/** |
| 86 | + * Pair up the footnote markers in a document with the definitions at the bottom of it. |
| 87 | + * |
| 88 | + * Returns undefined unless the document really does end in a footnote block whose markers are used in the |
| 89 | + * body — an ordinary bio that happens to contain `[a]` somewhere shouldn't grow tooltips. |
| 90 | + */ |
| 91 | +export function buildFootnoteIndex(doc: JSONContent | undefined): FootnoteIndex | undefined { |
| 92 | + const nodes = doc?.content |
| 93 | + if (!nodes?.length) return undefined |
| 94 | + |
| 95 | + const byLabel: Record<string, Footnote> = {} |
| 96 | + const definitionAt = new Map<number, string>() |
| 97 | + let continuations: string[] = [] |
| 98 | + |
| 99 | + // Walk up from the end of the document: continuation paragraphs are seen before the definition they |
| 100 | + // belong to, so they queue up until a marker turns up. |
| 101 | + for (let i = nodes.length - 1; i >= 0; i--) { |
| 102 | + const node = nodes[i] |
| 103 | + if (node.type !== 'paragraph') break |
| 104 | + |
| 105 | + const text = textOf(node).trim() |
| 106 | + const match = DEFINITION.exec(text) |
| 107 | + if (!match) { |
| 108 | + if (!text) continue // blank spacer paragraph |
| 109 | + if (continuations.length >= MAX_CONTINUATION_RUN) break |
| 110 | + continuations.unshift(text) |
| 111 | + continue |
| 112 | + } |
| 113 | + |
| 114 | + const label = match[1].toLowerCase() |
| 115 | + if (byLabel[label]) break // a repeated marker means we've walked back into ordinary prose |
| 116 | + |
| 117 | + byLabel[label] = { |
| 118 | + label, |
| 119 | + text: [text.slice(match[0].length), ...continuations].filter(Boolean).join('\n\n'), |
| 120 | + aliases: aliasesOf(node, label), |
| 121 | + } |
| 122 | + definitionAt.set(i, label) |
| 123 | + continuations = [] |
| 124 | + } |
| 125 | + |
| 126 | + if (!definitionAt.size) return undefined |
| 127 | + |
| 128 | + // Keep only the footnotes the body actually refers to. |
| 129 | + const bodyEnd = Math.min(...definitionAt.keys()) |
| 130 | + const referenced = new Set<string>() |
| 131 | + for (let i = 0; i < bodyEnd; i++) { |
| 132 | + forEachTextNode(nodes[i], (text) => { |
| 133 | + const label = MARKER.exec((text.text ?? '').trim())?.[1]?.toLowerCase() |
| 134 | + if (label) referenced.add(label) |
| 135 | + }) |
| 136 | + } |
| 137 | + |
| 138 | + for (const [i, label] of [...definitionAt]) { |
| 139 | + if (referenced.has(label)) continue |
| 140 | + delete byLabel[label] |
| 141 | + definitionAt.delete(i) |
| 142 | + } |
| 143 | + |
| 144 | + return definitionAt.size ? {byLabel, definitionAt} : undefined |
| 145 | +} |
0 commit comments