import JSZip from "jszip"; import { Create40kRoster } from "./roster40k"; import { Create40kRoster10th } from "./roster40k-10th"; import { useEffect, useMemo, useRef, useState } from "react"; import Demo0 from "./assets/Demo0.png"; import Demo1 from "./assets/Demo1.png"; import { Roster } from "./9th/Roster"; import { Roster as Roster10th } from "./10th/Roster"; import { useLocalStorage } from "./helpers/useLocalStorage"; import { parseJSON, stringifyJSON } from "./helpers/json"; import { Create40kRoster11th } from "./roster40k-11th"; import { applyTransforms, defaultToggles } from "./transforms"; // The transforms, in the order they are offered in the UI. These are what makes // this a fork: they rewrite the roster into the generic datasheets the official // cards print, rather than a record of one particular list. const TRANSFORMS = [ [ "mergeDuplicateUnits", "Merge duplicates", "Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.", ], [ "removeLeaderAbilities", "Drop Leader/Support", "Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.", ], [ "convertChoiceAbilities", "Split choice abilities", "Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.", ], ]; const PARSERS = { "Warhammer 40,000 9th Edition": [Create40kRoster, 9], "Warhammer 40,000 10th Edition": [Create40kRoster10th, 10], "Warhammer 40,000 11th Edition": [Create40kRoster11th, 11], }; const throttle = (func, limit) => { let lastFunc; let lastRan; return (...args) => { if (!lastRan) { func(...args); lastRan = Date.now(); } else { clearTimeout(lastFunc); lastFunc = setTimeout( () => { if (Date.now() - lastRan >= limit) { func(...args); lastRan = Date.now(); } }, limit - (Date.now() - lastRan), ); } }; }; function App() { // Saved rosters hold the *raw* roster XML rather than the parsed roster, so // that flipping a transform toggle can rebuild them without a re-upload. // The keys are BrevyScribe's own, so nothing upstream FancyScribe left in // localStorage is read back in the wrong shape. const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]"); const rostersJSON = parseJSON(rosters ?? "[]"); const [savedToggles, setSavedToggles] = useLocalStorage( "brevyscribe.transforms", stringifyJSON(defaultToggles), ); // Memoised so the rebuild effect can depend on the toggles themselves rather // than their serialised form; a fresh object every render would re-run it. const toggles = useMemo( () => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }), [savedToggles], ); // { xml, save } - the roster to show, and whether it belongs in the list. const [source, setSource] = useState(); const [error, setError] = useState(); const [roster, setRoster] = useState(); const [edition, setEdition] = useState(10); // [9, 10, 11] const [onePerPage, setOnePerPage] = useState(false); const [showLore, setShowLore] = useState(true); const [primaryColor, setPrimaryColor] = useState("#536766"); const [colorUserChoice, setColorUserChoice] = useState(false); const uploadRef = useRef(); const throttledSetPrimaryColor = useRef( throttle((color) => setPrimaryColor(color), 50), ).current; async function handleFileSelect(event) { const files = event?.target?.files; if (files) { const reader = new FileReader(); reader.onerror = () => { reader.abort(); setError("Failed to read roster file."); }; reader.onloadend = async () => { const content = reader.result; setSource({ xml: await unzip(content), save: true }); }; reader.readAsBinaryString(files[0]); } else { let example; switch (event) { case "ultras": example = await fetch("Ultramarines Example.rosz"); break; case "chaos daemons": example = await fetch("Chaos Demons Mix.rosz"); break; case "death guard": example = await fetch("Death Guard Example.rosz"); break; case "thousand sons": example = await fetch("Thousand Sons Example.rosz"); break; default: break; } if (!example) return; const arrayBuffer = await example.arrayBuffer(); // Create a new Blob object from the zip file contents const zipBlob = new Blob([arrayBuffer], { type: "application/zip" }); setSource({ xml: await unzip(zipBlob), save: false }); } } // Already in the list, so there is nothing to save; the stored XML goes // through the same rebuild as a fresh upload. const loadFromLocalStorage = (saved) => setSource({ xml: saved.xml, save: false }); const unzip = async (file) => { if (file?.charAt && file.charAt(0) !== "P") { console.log(file.replace(/\u00a0/g, " ")); return file .replaceAll("Â", " ") .replaceAll("â", "'") .replaceAll("â", "■"); // Â is a common issue with UTF-8 encoding in XML files } else { const jszip = new JSZip(); const zip = await jszip.loadAsync(file); return zip.file(/[^/]+\.ros/)[0].async("string"); // Get roster files that are in the root } }; // Rebuild whenever a new roster arrives or a transform is toggled. The // transforms run here rather than over the uploaded file, so flipping a // toggle rebuilds from the original XML and nothing has to be re-uploaded. // // biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is // read to append to it, so depending on it would re-run this on its own write. useEffect(() => { if (!source?.xml) return; const doc = new DOMParser().parseFromString(source.xml, "text/xml"); const info = doc?.querySelector("roster"); if (!info) return; // Determine roster type (game system). const gameType = info.getAttribute("gameSystemName"); if (!gameType) return; const parser = PARSERS[gameType]; if (!parser) { setError(`No support for game type '${gameType}'.`); return; } const report = applyTransforms(doc, toggles); const [createRoster, rosterEdition] = parser; const built = createRoster(doc, gameType); if (!built || built.forces.length === 0) return; setRoster(built); setEdition(rosterEdition); setError(""); const name = info.getAttribute("name"); if (name) document.title = `BrevyScribe ${name}`; console.log(built, report); if (source.save) { // Keyed on the XML, so re-saving the same roster moves it to the front // of the list rather than adding a second copy. setRosters( stringifyJSON([ { name: name || "Roster", xml: source.xml }, ...rostersJSON.filter((entry) => entry.xml !== source.xml), ]), ); } }, [source, toggles, setRosters]); useEffect(() => { // Check if the browser is Safari, and if so, remove the accept attribute // from the file input element. This is because Safari doesn't support // extensions on the accept attribute for input type=file // (https://caniuse.com/input-file-accept). If set, they will not allow any // file to be selected. if ( navigator.userAgent.match(/AppleWebKit.*Safari/) && !navigator.userAgent.includes("Chrome") ) { uploadRef.current?.removeAttribute("accept"); } }, []); useEffect(() => { if (roster) { js_colorPicker.value = getPrimaryColor(roster.forces[0].catalog); setPrimaryColor(getPrimaryColor(roster.forces[0].catalog)); setColorUserChoice(false); } }, [roster]); const handleDrag = (e) => { e.preventDefault(); e.stopPropagation(); }; useEffect(() => { if (edition === 9) { document.body.style.minWidth = "600px"; } else { document.body.style.minWidth = ""; } }, [edition]); return (