Fork FancyScribe as BrevyScribe with the datasheet transforms built in
CI / check (push) Has been cancelled
CI / check (push) Has been cancelled
Print generic datasheets - every option a unit could take, the way the official cards read - instead of a record of one particular list. The three Python scripts that used to rewrite the .ros file before upload are now transforms in src/transforms/, running in the browser between DOMParser and the roster parser. Most of each script was machinery for preserving the file byte for byte on the way back to disk; in the browser the document is never serialised, so only the domain logic came across. Because they run on the parsed document rather than the uploaded file, flipping a transform off rebuilds from the original XML with no re-upload. Saved rosters therefore hold the raw roster XML rather than the parsed object, under new localStorage keys. The scripts' output over four 11th-edition rosters is checked in as test fixtures, so the port is measured against an independent implementation that was verified by printing the cards; scripts/update-fixtures.mjs refuses to overwrite those without --force. Two parser call sites now use :scope> rather than repeating the scope element's own name, which means the same thing in a browser and also works under jsdom, where the old form matched nothing. Also: served at the root rather than a GitHub Pages subpath, PostHog analytics removed, and deploy/ carries an nginx site for scribe.luxick.de plus an rsync deploy script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+156
-110
@@ -1,7 +1,7 @@
|
||||
import JSZip from "jszip";
|
||||
import { Create40kRoster } from "./roster40k";
|
||||
import { Create40kRoster10th } from "./roster40k-10th";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import Demo0 from "./assets/Demo0.png";
|
||||
import Demo1 from "./assets/Demo1.png";
|
||||
@@ -10,6 +10,34 @@ 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;
|
||||
@@ -34,11 +62,27 @@ const throttle = (func, limit) => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [rosters, setRosters] = useLocalStorage("rosters", "[]");
|
||||
// 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]
|
||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||
const [onePerPage, setOnePerPage] = useState(false);
|
||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||
@@ -71,8 +115,7 @@ function App() {
|
||||
};
|
||||
reader.onloadend = async () => {
|
||||
const content = reader.result;
|
||||
const xmldata = await unzip(content);
|
||||
parseXML(xmldata, true);
|
||||
setSource({ xml: await unzip(content), save: true });
|
||||
};
|
||||
reader.readAsBinaryString(files[0]);
|
||||
} else {
|
||||
@@ -93,36 +136,18 @@ function App() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// load example
|
||||
posthog?.capture?.("user_loaded_example", {
|
||||
roster_faction: event,
|
||||
});
|
||||
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" });
|
||||
const xmldata = await unzip(zipBlob);
|
||||
parseXML(xmldata, false, true);
|
||||
setSource({ xml: await unzip(zipBlob), save: false });
|
||||
}
|
||||
}
|
||||
const loadFromLocalStorage = (roster) => {
|
||||
if (roster.gameType == "Warhammer 40,000 9th Edition") {
|
||||
setRoster(roster);
|
||||
setEdition(9);
|
||||
setError("");
|
||||
} else if (roster.gameType == "Warhammer 40,000 10th Edition") {
|
||||
setRoster(roster);
|
||||
setEdition(10);
|
||||
setError("");
|
||||
} else if (roster.gameType == "Warhammer 40,000 11th Edition") {
|
||||
setRoster(roster);
|
||||
setEdition(11);
|
||||
setError("");
|
||||
}
|
||||
posthog?.capture?.("user_loaded_roster_from_localstorage", {
|
||||
roster_faction: roster.forces[0].catalog,
|
||||
roster_type: roster.gameType,
|
||||
});
|
||||
};
|
||||
|
||||
// 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") {
|
||||
@@ -138,61 +163,52 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
function parseXML(xmldata, addToLocalStorage, isExample = false) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmldata, "text/xml");
|
||||
if (!doc) return;
|
||||
// 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;
|
||||
|
||||
// Determine roster type (game system).
|
||||
const info = doc.querySelector("roster");
|
||||
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 rosterName = info.getAttribute("name");
|
||||
if (rosterName) {
|
||||
document.title = `FancyScribe ${rosterName}`;
|
||||
}
|
||||
|
||||
let roster;
|
||||
if (gameType == "Warhammer 40,000 9th Edition") {
|
||||
roster = Create40kRoster(doc, gameType);
|
||||
if (roster && roster.forces.length > 0) {
|
||||
setRoster(roster);
|
||||
setEdition(9);
|
||||
setError("");
|
||||
}
|
||||
} else if (gameType == "Warhammer 40,000 10th Edition") {
|
||||
roster = Create40kRoster10th(doc, gameType);
|
||||
if (roster && roster.forces.length > 0) {
|
||||
setRoster(roster);
|
||||
setEdition(10);
|
||||
setError("");
|
||||
}
|
||||
} else if (gameType == "Warhammer 40,000 11th Edition") {
|
||||
roster = Create40kRoster11th(doc, gameType);
|
||||
if (roster && roster.forces.length > 0) {
|
||||
setRoster(roster);
|
||||
setEdition(11);
|
||||
setError("");
|
||||
}
|
||||
} else {
|
||||
setError("No support for game type '" + gameType + "'.");
|
||||
}
|
||||
if (!roster) {
|
||||
const parser = PARSERS[gameType];
|
||||
if (!parser) {
|
||||
setError(`No support for game type '${gameType}'.`);
|
||||
return;
|
||||
}
|
||||
if (addToLocalStorage) {
|
||||
console.log(roster);
|
||||
posthog?.capture?.("user_uploaded_roster", {
|
||||
roster_faction: roster.forces[0].catalog,
|
||||
roster_type: gameType,
|
||||
name: rosterName,
|
||||
});
|
||||
setRosters(stringifyJSON([roster, ...rostersJSON]));
|
||||
|
||||
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
|
||||
@@ -239,7 +255,7 @@ function App() {
|
||||
>
|
||||
<div className="header print-display-none">
|
||||
<a
|
||||
href="/fancyscribe"
|
||||
href="/"
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontWeight: 800,
|
||||
@@ -247,18 +263,18 @@ function App() {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
FancyScribe{" "}
|
||||
BrevyScribe{" "}
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Now with 11th edition support!
|
||||
Generic datasheets, not list-specific ones
|
||||
</span>
|
||||
</a>
|
||||
<div className="subheader">
|
||||
A fancy way to view your Warhammer 40k BattleScribe rosters
|
||||
A fancy way to print your Warhammer 40k datasheets
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -354,18 +370,47 @@ function App() {
|
||||
|
||||
<button
|
||||
style={{ display: roster ? "" : "none" }}
|
||||
onClick={() => {
|
||||
posthog?.capture?.("user_printed_roster", {
|
||||
roster_faction: roster.forces[0].catalog,
|
||||
roster_type: roster.gameType,
|
||||
});
|
||||
window.print();
|
||||
}}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
Print roster
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="print-display-none max-w-[95vw]"
|
||||
style={{
|
||||
display: roster ? "flex" : "none",
|
||||
width: "100%",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
|
||||
{TRANSFORMS.map(([key, label, description]) => (
|
||||
<label
|
||||
key={key}
|
||||
title={description}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
minHeight: 26,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggles[key]}
|
||||
onChange={(e) =>
|
||||
setSavedToggles(
|
||||
stringifyJSON({ ...toggles, [key]: e.target.checked }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="select-none">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="print-display-none max-w-[95vw]"
|
||||
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
|
||||
@@ -512,7 +557,7 @@ function App() {
|
||||
>
|
||||
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
|
||||
<div style={{ fontSize: "1.2rem" }}>
|
||||
FancyScribe is a website that renders{" "}
|
||||
BrevyScribe renders{" "}
|
||||
<a
|
||||
href="https://www.battlescribe.net/"
|
||||
target="_blank"
|
||||
@@ -521,9 +566,28 @@ function App() {
|
||||
BattleScribe
|
||||
</a>{" "}
|
||||
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
|
||||
in an opinionated format inspired by the new 10th edition datacards.
|
||||
Additional inspiration and large parts of the parsing logic come
|
||||
from the{" "}
|
||||
in an opinionated format inspired by the 10th edition datacards.
|
||||
</div>
|
||||
<div style={{ fontSize: "1.2rem" }}>
|
||||
It differs from its upstream in what it prints: rather than a record
|
||||
of one particular list, it rewrites the roster into the generic
|
||||
datasheets the official cards show. Duplicate copies of a unit are
|
||||
folded into one card carrying every option, the Leader and Support
|
||||
attachment rules are dropped, and a "select one each
|
||||
turn" ability is split into one titled row per option. Use the{" "}
|
||||
<b>Datasheets</b> toggles above to turn any of that off.
|
||||
</div>
|
||||
<div style={{ fontSize: "1.2rem" }}>
|
||||
BrevyScribe is a fork of{" "}
|
||||
<a
|
||||
href="https://github.com/NilsUeter/fancyscribe"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
FancyScribe
|
||||
</a>{" "}
|
||||
by Nils Ueter, which does all the heavy lifting here. Additional
|
||||
inspiration and large parts of the parsing logic come from the{" "}
|
||||
<a
|
||||
href="https://rweyrauch.github.io/PrettyScribe"
|
||||
target="_blank"
|
||||
@@ -533,24 +597,6 @@ function App() {
|
||||
</a>{" "}
|
||||
website.
|
||||
</div>
|
||||
<div style={{ fontSize: "1.2rem" }}>
|
||||
FancyScribe is an open-source project and can be found on Github (
|
||||
<a
|
||||
href="https://github.com/NilsUeter/fancyscribe"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
FancyScribe
|
||||
</a>
|
||||
).
|
||||
</div>
|
||||
<div style={{ fontSize: "1.2rem" }}>
|
||||
If you have any feedback or find any bugs, write{" "}
|
||||
<a href="https://www.reddit.com/r/WarhammerCompetitive/comments/13ajo3b/fancyscribe_convert_9th_edition_battlescribe">
|
||||
here
|
||||
</a>{" "}
|
||||
or send me a message.
|
||||
</div>
|
||||
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
|
||||
Output Examples
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user