382 lines
9.4 KiB
TypeScript
382 lines
9.4 KiB
TypeScript
// 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<string>();
|
|
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<string, number[]>();
|
|
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<string, DomElement[]>();
|
|
const removedMap = new Map<string, DomElement[]>();
|
|
|
|
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<DomElement[]> {
|
|
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<void> {
|
|
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<string, DomElement[]>();
|
|
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);
|
|
});
|