Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,27 @@ Characters and units should both be defined by quantity, however represent any u
| `characters` | Selected characters |
| `notes` | Optional user notes |
| `allowMercenaries` | Whether the catalog offers Regiments of Renown for hire (optional; absent reads as off) |

## Backup File Schema

A backup is the browser's whole collection in one file — every saved list, from every rule set. Importing one *replaces* the collection rather than merging into it, so a backup taken on one computer restores onto another as an exact mirror. List `id`s are therefore preserved (unlike share codes, which regenerate ids because they add a single list to an existing collection).

```json
{
"kind": "warmuster/backup",
"backupVersion": 1,
"exportedAt": "2026-08-06T12:00:00.000Z",
"lists": []
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

The `lists` array contains `SavedList` objects, exactly as described above.

| Field | Purpose |
| --------------- | ------------------------------------------------------------- |
| `kind` | Marks the file as a Warmuster backup; anything else is rejected |
| `backupVersion` | Version of the backup envelope; a file from a newer version is rejected rather than half-read |
| `exportedAt` | When the backup was taken (informational) |
| `lists` | Every saved list; an empty array is valid and clears the collection |

Entries in `lists` that aren't recognisable saved lists are dropped and the rest still restore.
28 changes: 23 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Catalog from "./components/Catalog";
import ConfigDialog from "./components/ConfigDialog";
import ExportDialog from "./components/ExportDialog";
Expand Down Expand Up @@ -31,7 +31,13 @@ import {
totalPoints,
} from "./domain/lists";
import { validateList } from "./domain/validation";
import { deleteList, listsForRuleSet, loadLists, upsertList } from "./storage/listRepository";
import {
deleteList,
listsForRuleSet,
loadLists,
replaceAllLists,
upsertList,
} from "./storage/listRepository";
import type { SavedList } from "./types";

type Theme = "light" | "dark";
Expand Down Expand Up @@ -66,6 +72,7 @@ export default function App() {
const [infoTopic, setInfoTopic] = useState<InfoTopic | null>(null);
const [configOpen, setConfigOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); // mobile list drawer
const restoreGeneration = useRef(0);
// Active rule set: each set has its own saved lists. Persisted per browser.
const [activeRuleSet, setActiveRuleSet] = useState<string>(() => {
const stored = localStorage.getItem("warmuster.ruleSet");
Expand Down Expand Up @@ -115,8 +122,9 @@ export default function App() {
useEffect(() => {
const code = consumeShareHash();
if (!code) return;
const generation = restoreGeneration.current;
void decodeShareCode(code).then((imported) => {
if (!imported) return;
if (!imported || generation !== restoreGeneration.current) return;
setLists((prev) => upsertList(prev, imported));
// Show the set the imported list belongs to so it's visible in the rail.
if (ruleSets.some((rs) => rs.id === imported.ruleSet)) setActiveRuleSet(imported.ruleSet);
Expand Down Expand Up @@ -167,6 +175,16 @@ export default function App() {
if (activeListId === id) setActiveListId(null);
};

// Restoring a backup swaps in another browser's whole collection, so the
// list open at the time is gone unless the backup happens to carry it.
const handleReplaceAllLists = (imported: SavedList[]) => {
if (!replaceAllLists(imported)) return false;
restoreGeneration.current += 1;
setLists(imported);
if (!imported.some((l) => l.id === activeListId)) setActiveListId(null);
return true;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleSelectRuleSet = (id: string) => {
if (id === activeRuleSet) return;
setActiveRuleSet(id);
Expand Down Expand Up @@ -284,6 +302,8 @@ export default function App() {
onSelectRuleSet={handleSelectRuleSet}
simplifiedView={simplifiedView}
onToggleSimplifiedView={setSimplifiedView}
lists={lists}
onReplaceAllLists={handleReplaceAllLists}
onClose={() => setConfigOpen(false)}
/>
)}
Expand Down Expand Up @@ -373,5 +393,3 @@ export default function App() {
</div>
);
}


114 changes: 111 additions & 3 deletions src/components/ConfigDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
import { useEffect } from "react";
import type { RuleSetInfo } from "../types";
import { useEffect, useRef, useState } from "react";
import type { RuleSetInfo, SavedList } from "../types";
import { backupFileName, parseBackup, serializeBackup } from "../domain/backup";

interface ConfigDialogProps {
ruleSets: RuleSetInfo[];
activeRuleSet: string;
onSelectRuleSet: (id: string) => void;
simplifiedView: boolean;
onToggleSimplifiedView: (value: boolean) => void;
/** Every saved list in this browser, across all rule sets. */
lists: SavedList[];
onReplaceAllLists: (lists: SavedList[]) => boolean;
onClose: () => void;
}

function downloadFile(name: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
}

/** A parsed backup waiting for the user to confirm the replace. */
type PendingImport = { fileName: string; lists: SavedList[] };

/** App configuration: the active rule set (each set keeps its own saved
* lists) and view preferences. */
* lists), view preferences, and whole-collection backup/restore. */
export default function ConfigDialog({
ruleSets,
activeRuleSet,
onSelectRuleSet,
simplifiedView,
onToggleSimplifiedView,
lists,
onReplaceAllLists,
onClose,
}: ConfigDialogProps) {
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<PendingImport | null>(null);
const [error, setError] = useState<string | null>(null);
const [imported, setImported] = useState(false);

useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
Expand All @@ -28,6 +51,31 @@ export default function ConfigDialog({
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);

const exportBackup = () => downloadFile(backupFileName(), serializeBackup(lists));

const pickFile = async (file: File | undefined) => {
if (!file) return;
setError(null);
setImported(false);
const parsed = parseBackup(await file.text());
if (!parsed) {
setError("That file isn't a Warmuster backup.");
return;
}
// Nothing is written yet — the replace happens on confirm below.
setPending({ fileName: file.name, lists: parsed });
};

const confirmImport = () => {
if (!pending) return;
if (!onReplaceAllLists(pending.lists)) {
setError("Could not save the restored lists. Check browser storage and try again.");
return;
}
setPending(null);
setImported(true);
};

return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label="Configuration" onClick={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -63,7 +111,67 @@ export default function ConfigDialog({
<span>Simplified view</span>
</label>
<p className="config-hint">Hides unit stats while building a list.</p>

<h3 className="panel-heading">Backup</h3>
<p className="config-hint">
Lists are saved in this browser only. A backup file holds every list from every rule
set — export it here, then import it on another computer to make that browser an exact
copy.
</p>
<div className="backup-actions">
<button type="button" className="primary-btn" onClick={exportBackup}>
⬇ Export all lists
</button>
<button type="button" className="primary-btn" onClick={() => fileInput.current?.click()}>
⬆ Import backup…
</button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
className="visually-hidden"
aria-label="Backup file"
onChange={(e) => {
void pickFile(e.target.files?.[0]);
// Reset so picking the same file twice fires onChange again.
e.target.value = "";
}}
/>
</div>
{error && <p className="config-error">{error}</p>}
{imported && <p className="config-hint">Lists replaced from the backup.</p>}
</div>
{pending && (
<div className="modal-backdrop" onClick={() => setPending(null)}>
<div
className="modal confirm-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="import-backup-title"
aria-describedby="import-backup-description"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-head">
<h2 id="import-backup-title">Replace all lists?</h2>
</div>
<p id="import-backup-description">
<strong>{pending.fileName}</strong> holds {pending.lists.length} list
{pending.lists.length === 1 ? "" : "s"}. Importing it permanently removes the{" "}
{lists.length} list{lists.length === 1 ? "" : "s"} saved in this browser and replaces
them with the backup's.
</p>
{error && <p className="config-error">{error}</p>}
<div className="confirm-actions">
<button type="button" autoFocus onClick={() => setPending(null)}>
Cancel
</button>
<button type="button" className="danger-btn" onClick={confirmImport}>
Replace my lists
</button>
</div>
</div>
</div>
)}
</div>
);
}
50 changes: 50 additions & 0 deletions src/components/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,54 @@ describe("MarkdownContent", () => {
"https://example.com/",
);
});

it("renders bold, italic and code spans", () => {
const { container } = render(
<MarkdownContent source={"Now **hired** into *any* army via `**literal**`."} />,
);

expect(container.querySelector("strong")).toHaveTextContent("hired");
expect(container.querySelector("em")).toHaveTextContent("any");
expect(container.querySelector("code")).toHaveTextContent("**literal**");
expect(container.querySelector("code strong")).toBeNull();
expect(container.querySelector("p")).toHaveTextContent(
"Now hired into any army via **literal**.",
);
});

it("formats inside headings and list items", () => {
const { container } = render(
<MarkdownContent source={"## A **bold** release\n\n- An **important** item"} />,
);

expect(container.querySelector("h3 strong")).toHaveTextContent("bold");
expect(container.querySelector("li strong")).toHaveTextContent("important");
});

it("nests other markup inside emphasis", () => {
const { container } = render(
<MarkdownContent source={"**Bold with a [link](https://example.com/) inside**"} />,
);

expect(container.querySelector("strong a")).toHaveAttribute("href", "https://example.com/");
});

it("nests bold text inside italic text", () => {
const { container } = render(
<MarkdownContent source={"*outer **inner** text*"} />,
);

expect(container.querySelector("em strong")).toHaveTextContent("inner");
});

it("leaves lone underscores and space-padded asterisks alone", () => {
const { container } = render(
<MarkdownContent source={"The unit_id field costs 2 * 3 points, or 4 * 5 upgraded."} />,
);

expect(container.querySelector("em")).toBeNull();
expect(container.querySelector("p")).toHaveTextContent(
"The unit_id field costs 2 * 3 points, or 4 * 5 upgraded.",
);
});
});
41 changes: 34 additions & 7 deletions src/components/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
import type { ReactNode } from "react";

// Inline spans, tried in this order at each position: link, bold, italic,
// code. Bold comes before italic so `**` isn't read as an empty italic. Only
// `*` marks italic — a lone `_` is left alone so identifiers like unit_id
// survive. Markers may not be padded with whitespace (Markdown's flanking
// rule), which keeps prose like "2 * 3 and 4 * 5" from turning italic. Code
// spans match last but their content is taken literally.
const INLINE_PATTERN =
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(\*\*\*|___)(?!\s)([\s\S]+?)(?<!\s)\3|(\*\*|__)(?!\s)([\s\S]+?)(?<!\s)\5|(?<!\*)\*(?![\s*])([\s\S]+?)(?<![\s*])\*(?!\*)|`([^`]+)`/;

function inlineContent(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const links = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
let cursor = 0;
let match: RegExpExecArray | null;
// Fresh matcher per call: emphasis recurses, and a shared /g regex would
// have its lastIndex clobbered by the inner scan.
const inline = new RegExp(INLINE_PATTERN, "g");

while ((match = links.exec(text)) != null) {
while ((match = inline.exec(text)) != null) {
if (match.index > cursor) nodes.push(text.slice(cursor, match.index));
nodes.push(
<a key={match.index} href={match[2]} target="_blank" rel="noreferrer">
{match[1]}
</a>,
);
const key = match.index;
if (match[1] != null) {
nodes.push(
<a key={key} href={match[2]} target="_blank" rel="noreferrer">
{match[1]}
</a>,
);
} else if (match[4] != null) {
nodes.push(
<strong key={key}>
<em>{inlineContent(match[4])}</em>
</strong>,
);
} else if (match[6] != null) {
// Emphasis can wrap other inline markup, so recurse into its content.
nodes.push(<strong key={key}>{inlineContent(match[6])}</strong>);
} else if (match[7] != null) {
nodes.push(<em key={key}>{inlineContent(match[7])}</em>);
} else {
nodes.push(<code key={key}>{match[8]}</code>);
}
cursor = match.index + match[0].length;
}

Expand Down
5 changes: 4 additions & 1 deletion src/components/Roster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getUnit, upgradesFor } from "../data/gameData";
import { isHired } from "../data/mercenaries";
import { armySizeMultiplier } from "../domain/armySize";
import { hiredCount } from "../domain/hiring";
import { entryPoints, entryStands, totalPoints } from "../domain/lists";
import { breakPoint, entryPoints, entryStands, totalPoints } from "../domain/lists";
import { getMagicItem, magicItemCost, type MagicItemData } from "../domain/magicItems";
import SpecialRules, { UnitDetailsDialog } from "./SpecialRules";
import UnitStats from "./UnitStats";
Expand Down Expand Up @@ -354,6 +354,9 @@ export default function Roster({
onChange={(e) => onRename(e.target.value)}
aria-label="List name"
/>
<span className="break-point" title="Half the army's non-character units, rounded up">
Breakpoint: <strong>{breakPoint(list)}</strong>
</span>
<span className="roster-army-name">{army.name}</span>
<label className="points-limit">
<input
Expand Down
Loading