Files
WebForm-mw/scripts/sync-help-from-civi.mjs
T
Joel Brock 2ca2d378a4 Add sync-help-from-civi script + field-group rendering
Two additions, both touching the form-config story:

1. scripts/sync-help-from-civi.mjs

   Diffs per-field help text in config/form.ts against CustomField rows
   in CiviCRM and (with --write) updates the file in place. Reads env
   from .env.local via Node's --env-file flag. Run as `npm run sync-help`
   or `npm run sync-help -- --write`. A --debug mode prints the parser's
   field list without calling Civi.

   Rationale: this form is low-traffic and help text doesn't change
   often once in production. A manual one-off sync is leaner than
   coupling every page load (or every build) to a Civi API call.

2. fieldGroups: visual clustering of related fields within a section

   New optional FieldGroupConfig overlay on StageSectionConfig — pure
   presentation, names existing fields by name so submit/visibility
   logic walks them unchanged. StageSection.tsx pulls grouped fields
   out of the standalone per-field grid and renders each group as its
   own bordered card with an optional heading. Stage 2 now clusters
   Market Study, Pro Forma, Business Plan, and Board Self Assessment
   (each a date + upload pair) into their own cards.
2026-05-21 12:27:22 -07:00

317 lines
11 KiB
JavaScript

#!/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);
});