#!/usr/bin/env node // scripts/sync-help-from-civi.mjs // // One-off / on-demand sync of per-field help text from CiviCRM into // config/form.ts. Civi stores help on CustomField as `help_pre` (shown // above the input) and `help_post` (shown below); this script prefers // help_pre and falls back to help_post. // // USAGE // node --env-file=.env.local scripts/sync-help-from-civi.mjs # dry run // node --env-file=.env.local scripts/sync-help-from-civi.mjs --write # apply changes // node scripts/sync-help-from-civi.mjs --debug # print parsed fields, no Civi call // // Or via npm: // npm run sync-help // npm run sync-help -- --write // // REQUIRED ENV // CIVI_BASE_URL, CIVI_API_KEY, CIVI_SITE_KEY // (optional) CIVI_HTTP_AUTH_USER, CIVI_HTTP_AUTH_PASS for sites behind Basic Auth. // // WHY A SCRIPT, NOT A RUNTIME FETCH // This form is low-traffic and the help text doesn't change once in // production use. Running this manually when staff edit help in Civi // is leaner than coupling every form load (or every build) to a Civi // API call. Keeps git history honest: every help-text change shows up // as a normal source edit you can review/revert. import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const args = process.argv.slice(2); const WRITE = args.includes("--write"); const DEBUG = args.includes("--debug"); // Keep in sync with config/form.ts G0..G5 declarations. If a new stage // custom group is added there, mirror it here. const GROUPS = { G0: "Check_in_data__organizing_", G1: "Stage_1", G2: "Stage_2", G3: "Stage_3", G4: "Stage_4", G5: "Stage_5", }; const HERE = dirname(fileURLToPath(import.meta.url)); const FORM_TS_PATH = resolve(HERE, "..", "config", "form.ts"); // ── CiviCRM APIv4 client (minimal, matches lib/civicrm.ts) ───────────── async function civi(entity, action, params) { const { CIVI_BASE_URL, CIVI_API_KEY, CIVI_SITE_KEY, CIVI_HTTP_AUTH_USER, CIVI_HTTP_AUTH_PASS, } = process.env; if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) { throw new Error( "Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY. " + "Put them in .env.local and run via `node --env-file=.env.local ...`.", ); } const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`; const headers = { "Content-Type": "application/x-www-form-urlencoded", "X-Civi-Auth": `Bearer ${CIVI_API_KEY}`, "X-Civi-Key": CIVI_SITE_KEY, }; if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) { headers["Authorization"] = "Basic " + Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64"); } const res = await fetch(url, { method: "POST", headers, body: new URLSearchParams({ params: JSON.stringify(params) }), }); if (!res.ok) { throw new Error( `Civi ${entity}.${action} failed (HTTP ${res.status}): ${await res.text()}`, ); } return res.json(); } async function fetchCiviHelp() { const result = await civi("CustomField", "get", { select: ["name", "custom_group_id.name", "help_pre", "help_post"], where: [["custom_group_id.name", "IN", Object.values(GROUPS)]], limit: 0, }); const map = new Map(); // "Group_Name.Field_Name" -> { pre, post } for (const row of result.values ?? []) { map.set(`${row["custom_group_id.name"]}.${row.name}`, { pre: row.help_pre ?? "", post: row.help_post ?? "", }); } return map; } // ── form.ts parser ───────────────────────────────────────────────────── // Build a masked copy of the file where `//` line comments and block // comments are replaced with spaces of equal length. Offsets stay // aligned with the original. We do NOT mask `${G0}`-style template- // literal substitutions: each one contains a matched `{` and `}` that // balance to net zero, so the brace walker ignores them naturally, and // keeping them intact lets the marker regex still capture the group key. function mask(text) { return text .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length)); } function parseFormTs(text) { const masked = mask(text); const fields = []; const markerRe = /\bciviField:\s*`\$\{(G\d)\}\.(\w+)`/g; let m; // Scan the masked text so commented-out `civiField:` lines don't get // picked up and paired with the wrong enclosing `name:`. while ((m = markerRe.exec(masked)) !== null) { // Backward walk: find the `{` that opens the enclosing object. let depth = 0; let openOff = -1; for (let i = m.index; i >= 0; i--) { const ch = masked[i]; if (ch === "}") depth++; else if (ch === "{") { if (depth === 0) { openOff = i; break; } depth--; } } if (openOff < 0) continue; // Forward walk: find the matching `}`. let closeOff = -1; depth = 0; for (let i = openOff; i < masked.length; i++) { const ch = masked[i]; if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) { closeOff = i; break; } } } if (closeOff < 0) continue; const block = text.slice(openOff, closeOff + 1); const nameM = block.match(/\bname:\s*"([^"]+)"/); if (!nameM) continue; const helpM = block.match(/\bhelp:\s*"((?:[^"\\]|\\.)*)"/); const helpIdxInBlock = helpM ? block.indexOf(helpM[0]) : -1; fields.push({ name: nameM[1], groupKey: m[1], civiName: m[2], currentHelp: helpM ? helpM[1] : null, blockStart: openOff, blockEnd: closeOff + 1, helpStart: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock : null, helpEnd: helpIdxInBlock >= 0 ? openOff + helpIdxInBlock + helpM[0].length : null, }); } return fields; } // ── Diff + in-place rewrite ──────────────────────────────────────────── function escForJsString(s) { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); } function buildChanges(fields, civiMap) { const changes = []; for (const f of fields) { const groupName = GROUPS[f.groupKey]; if (!groupName) continue; const civi = civiMap.get(`${groupName}.${f.civiName}`); if (!civi) { changes.push({ field: f, kind: "missing-in-civi" }); continue; } const civiHelp = (civi.pre || civi.post || "").trim(); if (!civiHelp) { // Civi has no help. Don't blank out a non-empty config help - surface it // for awareness but don't rewrite. if (f.currentHelp) changes.push({ field: f, kind: "civi-empty" }); continue; } if (f.currentHelp === civiHelp) continue; changes.push({ field: f, kind: f.currentHelp ? "differs" : "missing-in-config", civiHelp, }); } return changes; } function rewriteText(text, changes) { // Bottom-up so earlier offsets stay valid as we splice. const writable = changes .filter((c) => c.kind === "differs" || c.kind === "missing-in-config") .sort((a, b) => b.field.blockStart - a.field.blockStart); let out = text; for (const c of writable) { const f = c.field; const newLiteral = `help: "${escForJsString(c.civiHelp)}"`; if (f.helpStart != null) { out = out.slice(0, f.helpStart) + newLiteral + out.slice(f.helpEnd); continue; } const block = out.slice(f.blockStart, f.blockEnd); const isOneLine = !block.includes("\n"); if (isOneLine) { const closeIdx = f.blockEnd - 1; // position of `}` const before = out.slice(0, closeIdx); const trailM = before.match(/[\s,]+$/); const stripped = trailM ? before.slice(0, before.length - trailM[0].length) : before; out = stripped + `, ${newLiteral} ` + out.slice(closeIdx); } else { // Multi-line: insert a new help: line just before the closing `}` line, // using the indent of the first property after `{`. const indentM = block.match(/\{\s*\n([ \t]+)\S/); const indent = indentM ? indentM[1] : " "; const closeIdx = f.blockEnd - 1; const lastNL = out.lastIndexOf("\n", closeIdx); const beforeClose = out.slice(0, lastNL); const afterNL = out.slice(lastNL); out = beforeClose + "\n" + indent + newLiteral + "," + afterNL; } } return out; } function truncate(s, n = 90) { if (s.length <= n) return s; return s.slice(0, n - 1) + "..."; } // ── Main ─────────────────────────────────────────────────────────────── async function main() { const text = await readFile(FORM_TS_PATH, "utf8"); const fields = parseFormTs(text); console.log(`Parsed ${fields.length} field(s) with civiField from config/form.ts`); if (DEBUG) { for (const f of fields) { const grp = GROUPS[f.groupKey] ?? f.groupKey; console.log( ` - ${f.name} -> ${grp}.${f.civiName} ` + (f.currentHelp ? `(help: ${truncate(f.currentHelp, 60)})` : "(no help)"), ); } return; } const civiMap = await fetchCiviHelp(); console.log(`Fetched ${civiMap.size} CustomField row(s) from Civi`); const changes = buildChanges(fields, civiMap); if (changes.length === 0) { console.log("\nAll help text matches Civi. Nothing to do."); return; } console.log(`\n${changes.length} difference(s):\n`); for (const c of changes) { const f = c.field; switch (c.kind) { case "missing-in-civi": console.log(` - ${f.name}: no matching CustomField in Civi (orphan in form.ts?)`); if (f.currentHelp) console.log(` form: ${truncate(f.currentHelp)}`); break; case "civi-empty": console.log(` - ${f.name}: form has help, Civi help is empty (keeping form's value, no rewrite)`); console.log(` form: ${truncate(f.currentHelp)}`); break; case "missing-in-config": console.log(` - ${f.name}: missing in config, will add`); console.log(` civi: ${truncate(c.civiHelp)}`); break; case "differs": console.log(` - ${f.name}: differs`); console.log(` form: ${truncate(f.currentHelp)}`); console.log(` civi: ${truncate(c.civiHelp)}`); break; } } if (!WRITE) { console.log("\n(Dry run. Re-run with --write to apply changes.)"); return; } const updated = rewriteText(text, changes); await writeFile(FORM_TS_PATH, updated, "utf8"); console.log("\nWrote changes to config/form.ts"); } main().catch((e) => { console.error(e); process.exit(1); });