// Developer-only script: rewrite compact DOM dumps into step-to-step DIFFs. // NOT for production automation; intended to help understand UI transitions between numbered steps. // // Usage: // // 1. Generate compact DOM exports from HTML dumps (full DOM per step) using: // npx ts-node scripts/dom-export/exportHtmlDumps.ts // // 2. Then run THIS script to REPLACE those per-step JSON exports with DIFF objects: // npx ts-node scripts/dom-export/buildDomDiffs.ts html-dumps-optimized/iracing-hosted-sessions // // After step 2, each *.json in the target directory no longer contains the full DOM, // but only the diff relative to its predecessor. const fs = require("fs/promises"); const path = require("path"); type DomElement = { el: string; x?: string; t?: string; l?: string; p?: string; n?: string; i?: string; d?: string; r?: string; h?: string; [key: string]: unknown; }; type StepFile = { stepId: string; num: number; suffix: string; fileName: string; fullPath: string; }; type MatchedPair = { before: DomElement; after: DomElement; }; type DiffObject = { from: string | null; to: string; added: DomElement[]; removed: DomElement[]; modified: { before: DomElement; after: DomElement }[]; }; type IdentityProp = "i" | "t" | "l" | "p" | "n"; const identityProps: IdentityProp[] = ["i", "t", "l", "p", "n"]; function parseStepId(fileName: string): StepFile | null { const base = fileName.replace(/\.json$/i, ""); const m = /^(\d+)([a-z]?)/i.exec(base); if (!m) return null; const num = parseInt(m[1], 10); const suffix = (m[2] || "").toLowerCase(); return { stepId: m[1] + suffix, num, suffix, fileName, fullPath: "", }; } function sortSteps(steps: StepFile[]): StepFile[] { return [...steps].sort((a, b) => { if (a.num !== b.num) return a.num - b.num; const sa = a.suffix || ""; const sb = b.suffix || ""; if (sa === sb) return 0; if (!sa) return -1; if (!sb) return 1; return sa.localeCompare(sb); }); } function getPropValue(e: DomElement, prop: IdentityProp | "r"): string | null { const raw = e[prop]; if (typeof raw !== "string") return null; const value = raw.trim(); return value.length ? value : null; } function getIdentityKey(e: DomElement): string | null { for (const prop of identityProps) { const val = getPropValue(e, prop); if (val) return `${prop}:${val}`; } return null; } // Remove repeated entries that have the same identity + role. function dedupeElements(elems: DomElement[]): DomElement[] { const seen = new Set(); const result: DomElement[] = []; for (const elem of elems) { const key = getIdentityKey(elem); if (!key) { result.push(elem); continue; } const role = getPropValue(elem, "r") || ""; const composite = `${key}|role=${role}`; if (seen.has(composite)) continue; seen.add(composite); result.push(elem); } return result; } function isSameElement(a: DomElement, b: DomElement): boolean { const fields: (keyof DomElement)[] = [ "el", "t", "l", "p", "n", "i", "d", "r", ]; for (const f of fields) { const av = (a[f] as string | undefined) ?? null; const bv = (b[f] as string | undefined) ?? null; if (av !== bv) return false; } return true; } function computePairs( before: DomElement[], after: DomElement[] ): { matches: MatchedPair[]; remainingBefore: DomElement[]; remainingAfter: DomElement[]; } { const beforeIdx = new Set(before.map((_, i) => i)); const afterIdx = new Set(after.map((_, i) => i)); const matches: MatchedPair[] = []; const matchUsingProp = (prop: IdentityProp) => { const afterMap = new Map(); for (const ai of afterIdx) { const val = getPropValue(after[ai], prop); if (!val) continue; const arr = afterMap.get(val) || []; arr.push(ai); afterMap.set(val, arr); } for (const bi of [...beforeIdx]) { const val = getPropValue(before[bi], prop); if (!val) continue; const candidates = afterMap.get(val); if (!candidates || candidates.length !== 1) continue; const ai = candidates[0]; if (!afterIdx.has(ai)) continue; matches.push({ before: before[bi], after: after[ai] }); beforeIdx.delete(bi); afterIdx.delete(ai); } }; for (const prop of identityProps) { matchUsingProp(prop); } const remainingBefore = [...beforeIdx].map((i) => before[i]); const remainingAfter = [...afterIdx].map((i) => after[i]); return { matches, remainingBefore, remainingAfter }; } function reconcileDiff(diff: DiffObject): DiffObject { const modified = [...diff.modified]; const remainingAdded: DomElement[] = []; const remainingRemoved: DomElement[] = []; const addedMap = new Map(); const removedMap = new Map(); for (const elem of diff.added) { const key = getIdentityKey(elem); if (!key) { remainingAdded.push(elem); continue; } const arr = addedMap.get(key) || []; arr.push(elem); addedMap.set(key, arr); } for (const elem of diff.removed) { const key = getIdentityKey(elem); if (!key) { remainingRemoved.push(elem); continue; } const arr = removedMap.get(key) || []; arr.push(elem); removedMap.set(key, arr); } for (const [key, addList] of addedMap.entries()) { const remList = removedMap.get(key); if (!remList) { remainingAdded.push(...addList); continue; } const pairCount = Math.min(addList.length, remList.length); for (let i = 0; i < pairCount; i++) { const addedElem = addList[i]; const removedElem = remList[i]; if (isSameElement(removedElem, addedElem)) { continue; } modified.push({ before: removedElem, after: addedElem }); } if (addList.length > pairCount) { remainingAdded.push(...addList.slice(pairCount)); } if (remList.length > pairCount) { remainingRemoved.push(...remList.slice(pairCount)); } removedMap.delete(key); } for (const leftovers of removedMap.values()) { remainingRemoved.push(...leftovers); } return { from: diff.from, to: diff.to, added: remainingAdded, removed: remainingRemoved, modified, }; } function computeDiffObject( fromId: string | null, toId: string, before: DomElement[] | null, after: DomElement[] ): DiffObject { const normalizedAfter = dedupeElements(after); if (!before) { return reconcileDiff({ from: null, to: toId, added: normalizedAfter, removed: [], modified: [], }); } const normalizedBefore = dedupeElements(before); const { matches, remainingBefore, remainingAfter } = computePairs( normalizedBefore, normalizedAfter ); const modified: { before: DomElement; after: DomElement }[] = []; for (const m of matches) { if (!isSameElement(m.before, m.after)) { modified.push(m); } } return reconcileDiff({ from: fromId, to: toId, added: remainingAfter, removed: remainingBefore, modified, }); } async function loadDomArray(filePath: string): Promise { const raw = await fs.readFile(filePath, "utf8"); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return parsed as DomElement[]; } if ( parsed && typeof parsed === "object" && Array.isArray((parsed as { added?: unknown }).added) && Array.isArray((parsed as { removed?: unknown }).removed) && Array.isArray((parsed as { modified?: unknown }).modified) ) { throw new Error( `File already looks like a diff, not a raw DOM array: ${filePath}` ); } throw new Error( `Unexpected JSON structure in ${filePath}; expected an array of DOM elements.` ); } async function rewriteDomExportsAsDiffs(dir: string): Promise { const entries = await fs.readdir(dir); const stepFiles: StepFile[] = []; for (const name of entries) { if (!name.endsWith(".json")) continue; const parsed = parseStepId(name); if (!parsed) continue; parsed.fullPath = path.join(dir, name); stepFiles.push(parsed); } if (stepFiles.length === 0) { console.log("No JSON files with step-like names found in", dir); return; } const ordered = sortSteps(stepFiles); const domByStep = new Map(); for (const step of ordered) { const arr = await loadDomArray(step.fullPath); domByStep.set(step.stepId, arr); } const first = ordered[0]; const firstDom = domByStep.get(first.stepId)!; const firstDiff = computeDiffObject(null, first.stepId, null, firstDom); await fs.writeFile( first.fullPath, JSON.stringify(firstDiff, null, 2), "utf8" ); for (let i = 0; i < ordered.length - 1; i++) { const from = ordered[i]; const to = ordered[i + 1]; const before = domByStep.get(from.stepId)!; const after = domByStep.get(to.stepId)!; const diff = computeDiffObject(from.stepId, to.stepId, before, after); await fs.writeFile(to.fullPath, JSON.stringify(diff, null, 2), "utf8"); } console.log( `Rewrote ${ordered.length} JSON exports in ${dir} as step-to-step diffs.` ); } async function main() { const argDir = process.argv[2]; const dir = argDir ? path.resolve(process.cwd(), argDir) : path.join( process.cwd(), "html-dumps-optimized", "iracing-hosted-sessions" ); await rewriteDomExportsAsDiffs(dir); } main().catch((err) => { console.error(err); process.exit(1); });