// Small DOM helpers shared by the transforms. // // The Python scripts these were ported from parse the roster into a tree of // text offsets and edit the file as text, so that everything they do not touch // survives byte for byte. In the browser there is nothing to preserve: the // document goes straight into the roster parser and is never written back out, // so the transforms mutate the DOM directly and all of that machinery // (Node/Edit/apply_edits/attr escaping/XML re-validation) is gone. // Roster files declare a default namespace (the BattleScribe roster schema), so // everything here matches on `localName` and creates elements in the document's // own namespace. Matching on `tagName` would still work for these files, but // only because nothing in them carries a prefix. /** Element children of `node`, as a real array (safe to mutate while iterating). */ export const elementChildren = (node) => Array.from(node.children); /** The first element child named `name`, or null. */ export const childElement = (node, name) => elementChildren(node).find((child) => child.localName === name) ?? null; /** Every descendant element named `name`, in document order. */ export const descendants = (node, name) => Array.from(node.getElementsByTagNameNS("*", name)); /** Create an element in the same namespace as the document's root. */ export const createElement = (doc, name) => doc.createElementNS(doc.documentElement.namespaceURI, name); /** * Remove `node`, taking the whitespace that only separated it from its sibling. * * Rosters come out of BattleScribe on a single line, so usually there is no * whitespace to take; this only keeps the result tidy for a roster that has * been pretty-printed at some point. */ export function removeElement(node) { const previous = node.previousSibling; const TEXT_NODE = 3; if ( previous && previous.nodeType === TEXT_NODE && previous.data.trim() === "" ) { previous.remove(); } node.remove(); } /** * Insert `child` into `parent`, keeping `order` among the containers listed in * it. A container not mentioned in `order` is appended. */ export function insertOrdered(parent, child, order) { const rank = order.indexOf(child.localName); if (rank !== -1) { const before = elementChildren(parent).find((existing) => { const existingRank = order.indexOf(existing.localName); return existingRank !== -1 && existingRank > rank; }); if (before) { parent.insertBefore(child, before); return; } } parent.appendChild(child); } /** * Joins the parts of a key or a generated id, so that ("ab", "c") and * ("a", "bc") cannot collide. * * ASCII unit separator: a control character cannot occur in a unit name, an * ability name or an id, so nothing in a roster can contain it and no pair of * different parts can ever join to the same string. */ export const KEY_SEPARATOR = String.fromCharCode(31); /** * A stable id derived from `parts`, shaped like BattleScribe's * ``xxxx-xxxx-xxxx-xxxx``. * * Deterministic so that re-running against an updated roster produces the same * ids for the same ability rather than churning them, which is what lets the * merge recognise two generated profiles as the same catalogue object. This is * a plain string hash rather than the SHA-1 the Python script uses: the ids only * have to be unique and stable within one document, and `crypto.subtle` is * async, which would push a promise through every call site for no gain. */ export function makeId(...parts) { const input = parts.join(KEY_SEPARATOR); // Two independent FNV-1a passes, giving 64 bits of output. let h1 = 0x811c9dc5; let h2 = 0x01000193; for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i); h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0; h2 = Math.imul(h2 ^ code, 0x811c9dc5) >>> 0; } const hex = h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; } /** * The datasheet an element sits on, for the run report: the outermost * `` above it, since that is the force-level unit. */ export function enclosingUnit(node) { let unit = null; for ( let current = node.parentElement; current; current = current.parentElement ) { if (current.localName === "selection") { unit = current.getAttribute("name") ?? "?"; } else if (current.localName === "force" && !unit) { return current.getAttribute("name") ?? "Force"; } } return unit ?? "Roster"; }