spb/note-spboucher-ai
Public
1/** BibTeX helpers: parse (bibtex-parse-js), normalise, format. Pure functions, no DB. */2import { toJSON } from "bibtex-parse-js";34export interface ParsedRef { citeKey: string; entryType: string; title: string; authors: string[]; year: number | null; journal: string | null; doi: string | null; url: string | null; fields: Record<string, string>; bibtex: string }56const stripBraces = (s: string) => s.replace(/[{}]/g, "").replace(/\s+/g, " ").trim();7const ACCENTS: Record<string, Record<string, string>> = { "'": { a: "á", e: "é", i: "í", o: "ó", u: "ú", y: "ý", c: "ć", n: "ń", s: "ś", z: "ź", A: "Á", E: "É", I: "Í", O: "Ó", U: "Ú" }, "`": { a: "à", e: "è", i: "ì", o: "ò", u: "ù", A: "À", E: "È", U: "Ù" }, "^": { a: "â", e: "ê", i: "î", o: "ô", u: "û", A: "Â", E: "Ê", O: "Ô" }, '"': { a: "ä", e: "ë", i: "ï", o: "ö", u: "ü", y: "ÿ", A: "Ä", O: "Ö", U: "Ü" }, "~": { a: "ã", n: "ñ", o: "õ", A: "Ã", N: "Ñ", O: "Õ" }, c: { c: "ç", C: "Ç" }, v: { c: "č", s: "š", z: "ž", r: "ř", e: "ě", C: "Č", S: "Š", Z: "Ž" }, "=": { a: "ā", e: "ē", o: "ō" } };8const SPECIAL: Record<string, string> = { aa: "å", AA: "Å", o: "ø", O: "Ø", l: "ł", L: "Ł", ss: "ß", ae: "æ", AE: "Æ", oe: "œ", OE: "Œ" };9export function cleanLatex(s: string) {10 let t = s.replace(/\\([`'^"~=cv])\s*\{?(\w)\}?/g, (_m, acc: string, ch: string) => ACCENTS[acc]?.[ch] ?? ch).replace(/\\(aa|AA|ae|AE|oe|OE|o|O|l|L|ss)\b\{?\}?/g, (_m, k: string) => SPECIAL[k] ?? k);11 t = t.replace(/\\(?:emph|textit|textbf|mkbibquote|url)\{([^}]*)\}/g, "$1").replace(/---?/g, "–").replace(/~/g, " ");12 return stripBraces(t);13}1415/** "Last, First and First Last and {Bank of Canada}" → ["First Last", …] */16export function parseAuthors(s: string | undefined | null): string[] {17 if (!s) return [];18 return s.split(/\s+and\s+/i).map((a) => a.trim()).filter(Boolean).map((a) => {19 if (/^\{.*\}$/.test(a)) return stripBraces(a);20 const parts = a.split(",").map((x) => cleanLatex(x));21 if (parts.length >= 2) return `${parts[1]} ${parts[0]}`.replace(/\s+/g, " ").trim();22 return cleanLatex(a);23 });24}25export const lastName = (full: string) => { const p = full.trim().split(/\s+/); return p[p.length - 1] ?? ""; };26export const normDoi = (d: string | null | undefined) => { if (!d) return null; const m = d.trim().replace(/^(https?:\/\/)?(dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, ""); return /^10\.\d{4,9}\/\S+$/i.test(m) ? m.toLowerCase() : null; };2728/** citeKey from authors/year/title: gorton2019does */29export function makeCiteKey(authors: string[], year: number | null, title: string) {30 const ln = (authors[0] ? lastName(authors[0]) : "anon").normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z]/g, "").toLowerCase() || "anon";31 const w = title.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().split(/[^a-z0-9]+/).filter((x) => x.length > 2 && !STOP.has(x))[0] ?? "";32 return `${ln}${year ?? ""}${w}`;33}34const STOP = new Set(["the", "and", "for", "with", "from", "that", "this", "does", "are", "was", "how", "why", "what", "when", "into", "over", "under", "between", "des", "les", "une", "sur", "dans", "pour"]);3536/** Strip @string/@preamble/@comment blocks (bibtex-parse-js chokes on @string). */37function preclean(src: string) { return src.replace(/^/, "").replace(/@(string|preamble|comment)\s*[{(][\s\S]*?[})]\s*$/gim, "").replace(/^%.*$/gm, ""); }3839export function parseBibtex(src: string): ParsedRef[] {40 const text = preclean(src);41 let entries: ReturnType<typeof toJSON> = [];42 try { entries = toJSON(text); } catch (e) { throw new Error("Could not parse BibTeX: " + String((e as any)?.message ?? e).slice(0, 200)); }43 const out: ParsedRef[] = [];44 for (const e of entries) {45 if (!e.entryTags || !e.citationKey) continue;46 const tags: Record<string, string> = {}; for (const [k, v] of Object.entries(e.entryTags)) tags[k.toLowerCase()] = String(v).replace(/\s+/g, " ").trim();47 const authors = parseAuthors(tags.author ?? tags.editor);48 const year = tags.year ? Number((tags.year.match(/\d{4}/) ?? [])[0]) || null : null;49 const title = cleanLatex(tags.title ?? "") || "Untitled";50 const journal = cleanLatex(tags.journal ?? tags.booktitle ?? tags.publisher ?? tags.institution ?? tags.school ?? "") || null;51 const doi = normDoi(tags.doi); if (doi) tags.doi = doi;52 const url = tags.url ?? (doi ? `https://doi.org/${doi}` : null);53 const citeKey = e.citationKey.trim() || makeCiteKey(authors, year, title);54 const entryType = e.entryType.toLowerCase();55 out.push({ citeKey, entryType, title, authors, year, journal, doi, url, fields: tags, bibtex: formatBibtex({ citeKey, entryType, fields: tags }) });56 }57 return out;58}5960const ORDER = ["author", "title", "journal", "booktitle", "year", "volume", "number", "pages", "publisher", "institution", "school", "doi", "url", "abstract", "keywords", "note"];61/** Deterministic BibTeX entry. */62export function formatBibtex(e: { citeKey: string; entryType: string; fields: Record<string, unknown> }) {63 const keys = Object.keys(e.fields).filter((k) => e.fields[k] != null && String(e.fields[k]).trim() !== "").sort((a, b) => (ORDER.indexOf(a) === -1 ? 99 : ORDER.indexOf(a)) - (ORDER.indexOf(b) === -1 ? 99 : ORDER.indexOf(b)) || a.localeCompare(b));64 const pad = Math.max(6, ...keys.map((k) => k.length));65 return `@${e.entryType}{${e.citeKey},\n${keys.map((k) => ` ${k.padEnd(pad)} = {${String(e.fields[k]).replace(/\n+/g, " ").trim()}}`).join(",\n")}\n}`;66}67/** Build the field map of a reference row (merges structured columns into fields). */68export function fieldsOf(r: { title: string; authors?: string[] | null; year?: number | null; journal?: string | null; doi?: string | null; url?: string | null; entryType: string; fields?: unknown }) {69 const f: Record<string, unknown> = { ...((r.fields as Record<string, unknown>) ?? {}) };70 if (r.authors?.length) f.author = r.authors.map((a) => { const p = a.trim().split(/\s+/); return p.length > 1 ? `${p[p.length - 1]}, ${p.slice(0, -1).join(" ")}` : a; }).join(" and ");71 f.title = r.title; if (r.year) f.year = String(r.year);72 if (r.journal) f[["book", "inproceedings", "incollection"].includes(r.entryType) ? (r.entryType === "book" ? "publisher" : "booktitle") : "journal"] = r.journal;73 if (r.doi) f.doi = r.doi; if (r.url && !f.url) f.url = r.url;74 return f;75}76/** Short in-text label: "Gorton & Metrick (2019)". */77export function shortCite(r: { authors?: string[] | null; year?: number | null; citeKey?: string }) {78 const a = (r.authors ?? []).map(lastName).filter(Boolean);79 const who = a.length === 0 ? (r.citeKey ?? "?") : a.length === 1 ? a[0] : a.length === 2 ? `${a[0]} & ${a[1]}` : `${a[0]} et al.`;80 return `${who}${r.year ? ` (${r.year})` : ""}`;81}82