diff --git a/schema.md b/schema.md index f004d12..8dcf353 100644 --- a/schema.md +++ b/schema.md @@ -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": [] +} +``` + +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. diff --git a/src/App.tsx b/src/App.tsx index f2c67ad..8f8950a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -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"; @@ -66,6 +72,7 @@ export default function App() { const [infoTopic, setInfoTopic] = useState(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(() => { const stored = localStorage.getItem("warmuster.ruleSet"); @@ -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); @@ -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; + }; + const handleSelectRuleSet = (id: string) => { if (id === activeRuleSet) return; setActiveRuleSet(id); @@ -284,6 +302,8 @@ export default function App() { onSelectRuleSet={handleSelectRuleSet} simplifiedView={simplifiedView} onToggleSimplifiedView={setSimplifiedView} + lists={lists} + onReplaceAllLists={handleReplaceAllLists} onClose={() => setConfigOpen(false)} /> )} @@ -373,5 +393,3 @@ export default function App() { ); } - - diff --git a/src/components/ConfigDialog.tsx b/src/components/ConfigDialog.tsx index 87b0342..d7c7113 100644 --- a/src/components/ConfigDialog.tsx +++ b/src/components/ConfigDialog.tsx @@ -1,5 +1,6 @@ -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[]; @@ -7,19 +8,41 @@ interface ConfigDialogProps { 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(null); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const [imported, setImported] = useState(false); + useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); @@ -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 (
e.stopPropagation()}> @@ -63,7 +111,67 @@ export default function ConfigDialog({ Simplified view

Hides unit stats while building a list.

+ +

Backup

+

+ 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. +

+
+ + + { + void pickFile(e.target.files?.[0]); + // Reset so picking the same file twice fires onChange again. + e.target.value = ""; + }} + /> +
+ {error &&

{error}

} + {imported &&

Lists replaced from the backup.

}
+ {pending && ( +
setPending(null)}> +
event.stopPropagation()} + > +
+

Replace all lists?

+
+

+ {pending.fileName} 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. +

+ {error &&

{error}

} +
+ + +
+
+
+ )}
); } diff --git a/src/components/MarkdownContent.test.tsx b/src/components/MarkdownContent.test.tsx index f716bcc..2be9a57 100644 --- a/src/components/MarkdownContent.test.tsx +++ b/src/components/MarkdownContent.test.tsx @@ -17,4 +17,54 @@ describe("MarkdownContent", () => { "https://example.com/", ); }); + + it("renders bold, italic and code spans", () => { + const { container } = render( + , + ); + + 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( + , + ); + + expect(container.querySelector("h3 strong")).toHaveTextContent("bold"); + expect(container.querySelector("li strong")).toHaveTextContent("important"); + }); + + it("nests other markup inside emphasis", () => { + const { container } = render( + , + ); + + expect(container.querySelector("strong a")).toHaveAttribute("href", "https://example.com/"); + }); + + it("nests bold text inside italic text", () => { + const { container } = render( + , + ); + + expect(container.querySelector("em strong")).toHaveTextContent("inner"); + }); + + it("leaves lone underscores and space-padded asterisks alone", () => { + const { container } = render( + , + ); + + expect(container.querySelector("em")).toBeNull(); + expect(container.querySelector("p")).toHaveTextContent( + "The unit_id field costs 2 * 3 points, or 4 * 5 upgraded.", + ); + }); }); diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx index dfe0cdb..4298cf3 100644 --- a/src/components/MarkdownContent.tsx +++ b/src/components/MarkdownContent.tsx @@ -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]+?)(? cursor) nodes.push(text.slice(cursor, match.index)); - nodes.push( - - {match[1]} - , - ); + const key = match.index; + if (match[1] != null) { + nodes.push( + + {match[1]} + , + ); + } else if (match[4] != null) { + nodes.push( + + {inlineContent(match[4])} + , + ); + } else if (match[6] != null) { + // Emphasis can wrap other inline markup, so recurse into its content. + nodes.push({inlineContent(match[6])}); + } else if (match[7] != null) { + nodes.push({inlineContent(match[7])}); + } else { + nodes.push({match[8]}); + } cursor = match.index + match[0].length; } diff --git a/src/components/Roster.tsx b/src/components/Roster.tsx index bc974eb..b84d803 100644 --- a/src/components/Roster.tsx +++ b/src/components/Roster.tsx @@ -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"; @@ -354,6 +354,9 @@ export default function Roster({ onChange={(e) => onRename(e.target.value)} aria-label="List name" /> + + Breakpoint: {breakPoint(list)} + {army.name}