Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 23:29:34 +01:00
parent c323adc343
commit 94d125d9c4
14 changed files with 850 additions and 106 deletions

View File

@@ -17,8 +17,8 @@ interface RawMonster {
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] })[];
hp: { average: number; formula: string };
ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average?: number; formula?: string; special?: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
@@ -38,7 +38,7 @@ interface RawMonster {
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr: string | { cr: string };
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
@@ -140,7 +140,12 @@ function formatType(
let result = baseType;
if (type.tags && type.tags.length > 0) {
result += ` (${type.tags.map(capitalize).join(", ")})`;
const tagStrs = type.tags
.filter((t): t is string => typeof t === "string")
.map(capitalize);
if (tagStrs.length > 0) {
result += ` (${tagStrs.join(", ")})`;
}
}
if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
@@ -161,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
if (typeof first === "number") {
return { value: first };
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = first.special.match(/^(\d+)/);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
};
}
return {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
@@ -339,7 +352,8 @@ function normalizeLegendary(
};
}
function extractCr(cr: string | { cr: string }): string {
function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr;
}
@@ -355,60 +369,81 @@ function makeCreatureId(source: string, name: string): CreatureId {
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// Filter out _copy entries — these reference another source's monster
// and lack their own stats (ac, hp, cr, etc.)
const monsters = raw.monster.filter(
// Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
(m) => !(m as any)._copy,
);
return monsters.map((m) => {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula },
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
if ((m as any)._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&
m.hp !== undefined &&
Array.isArray(m.size) &&
m.size.length > 0 &&
m.type !== undefined
);
});
const creatures: Creature[] = [];
for (const m of monsters) {
try {
creatures.push(normalizeMonster(m));
} catch {
// Skip monsters with unexpected data shapes
}
}
return creatures;
}
function normalizeMonster(m: RawMonster): Creature {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
}

View File

@@ -33,21 +33,61 @@ function mapCreature(c: CompactCreature): BestiaryIndexEntry {
};
}
// Source codes whose filename on the remote differs from a simple lowercase.
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
const FILENAME_OVERRIDES: Record<string, string> = {
PSA: "ps-a",
PSD: "ps-d",
PSI: "ps-i",
PSK: "ps-k",
PSX: "ps-x",
PSZ: "ps-z",
};
// Source codes with no corresponding remote bestiary file.
// Excluded from the index entirely so creatures aren't searchable
// without a fetchable source.
const EXCLUDED_SOURCES = new Set<string>([]);
let cachedIndex: BestiaryIndex | undefined;
export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex;
const sources = Object.fromEntries(
Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code),
),
);
cachedIndex = {
sources: compact.sources,
creatures: compact.creatures.map(mapCreature),
sources,
creatures: compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature),
};
return cachedIndex;
}
export function getDefaultFetchUrl(sourceCode: string): string {
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-${sourceCode.toLowerCase()}.json`;
export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex();
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
}
function sourceCodeToFilename(sourceCode: string): string {
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
}
export function getDefaultFetchUrl(
sourceCode: string,
baseUrl?: string,
): string {
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
if (baseUrl !== undefined) {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${filename}`;
}
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
}
export function getSourceDisplayName(sourceCode: string): string {

View File

@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
* Handles 15+ tag types per research.md R-002 tag resolution rules.
*/
export function stripTags(text: string): string {
if (typeof text !== "string") return String(text);
// Process special tags with specific output formats first
let result = text;