From 248e0a0a6b7a5ab21d2451eaaa1d51dc953834d0 Mon Sep 17 00:00:00 2001 From: tpskowski <98412204+tpskowski@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:47:04 -0400 Subject: [PATCH 1/3] ## 0.7.0 2026-08-06 - A list's header now shows its **Breakpoint** - half its non-character units, rounded up. - **Backup and restore** under Configuration. Export every list to a file, then import it on another computer to make that browser an exact copy. --- schema.md | 20 +++++ src/App.tsx | 17 +++- src/components/ConfigDialog.tsx | 110 +++++++++++++++++++++++- src/components/MarkdownContent.test.tsx | 41 +++++++++ src/components/MarkdownContent.tsx | 35 ++++++-- src/components/Roster.tsx | 5 +- src/content/info/changelog.md | 12 ++- src/content/info/privacy.md | 2 + src/content/info/roadmap.md | 2 +- src/domain/backup.test.ts | 66 ++++++++++++++ src/domain/backup.ts | 104 ++++++++++++++++++++++ src/domain/lists.test.ts | 12 +++ src/domain/lists.ts | 7 ++ src/storage/listRepository.ts | 7 ++ src/styles.css | 40 +++++++++ 15 files changed, 463 insertions(+), 17 deletions(-) create mode 100644 src/domain/backup.test.ts create mode 100644 src/domain/backup.ts diff --git a/schema.md b/schema.md index f004d12..1147f98 100644 --- a/schema.md +++ b/schema.md @@ -646,3 +646,23 @@ 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). + +{ + "kind": "warmuster/backup", + "backupVersion": 1, + "exportedAt": "2026-08-06T12:00:00.000Z", + "lists": [ /* SavedList objects, exactly as 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..2757080 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -167,6 +173,13 @@ 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[]) => { + setLists(replaceAllLists(imported)); + if (!imported.some((l) => l.id === activeListId)) setActiveListId(null); + }; + const handleSelectRuleSet = (id: string) => { if (id === activeRuleSet) return; setActiveRuleSet(id); @@ -284,6 +297,8 @@ export default function App() { onSelectRuleSet={handleSelectRuleSet} simplifiedView={simplifiedView} onToggleSimplifiedView={setSimplifiedView} + lists={lists} + onReplaceAllLists={handleReplaceAllLists} onClose={() => setConfigOpen(false)} /> )} diff --git a/src/components/ConfigDialog.tsx b/src/components/ConfigDialog.tsx index 87b0342..60cec58 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[]) => void; 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,28 @@ 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; + onReplaceAllLists(pending.lists); + setPending(null); + setImported(true); + }; + return (
e.stopPropagation()}> @@ -63,7 +108,66 @@ 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. +

+
+ + +
+
+
+ )}
); } diff --git a/src/components/MarkdownContent.test.tsx b/src/components/MarkdownContent.test.tsx index f716bcc..e7cb5df 100644 --- a/src/components/MarkdownContent.test.tsx +++ b/src/components/MarkdownContent.test.tsx @@ -17,4 +17,45 @@ 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("allowMercenaries"); + expect(container.querySelector("p")).toHaveTextContent( + "Now hired into any army via allowMercenaries.", + ); + }); + + 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("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..26327fe 100644 --- a/src/components/MarkdownContent.tsx +++ b/src/components/MarkdownContent.tsx @@ -1,18 +1,39 @@ 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) { + // Emphasis can wrap other inline markup, so recurse into its content. + nodes.push({inlineContent(match[4])}); + } else if (match[5] != null) { + nodes.push({inlineContent(match[5])}); + } else { + nodes.push({match[6]}); + } 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}