initial commit

This commit is contained in:
CPTN Cosmo 2026-01-23 20:06:09 +01:00
commit 4ebe86ee8c
No known key found for this signature in database
8 changed files with 1006 additions and 0 deletions

114
scripts/app.js Normal file
View file

@ -0,0 +1,114 @@
import { DHImporter } from "./importer.js";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(options) {
super(options);
this.step = "input";
this.parsedData = null;
this.parsedDataType = null;
}
static DEFAULT_OPTIONS = {
id: "dh-importer",
tag: "form",
classes: ["daggerheart", "dh-style", "dh-importer-app"],
window: {
title: "Daggerheart Importer",
resizable: true,
icon: "fas fa-file-import"
},
position: {
width: 600,
height: 700
},
actions: {
parse: DHImporterApp.onParse,
import: DHImporterApp.onImport,
back: DHImporterApp.onBack
}
};
static PARTS = {
main: {
template: "modules/dh-importer/templates/importer.hbs"
}
};
async _prepareContext(options) {
return {
step: this.step,
parsedData: this.parsedData,
isInput: this.step === "input",
isPreview: this.step === "preview",
types: {
adversary: "Adversary",
environment: "Environment"
}
};
}
static async onParse(event, target) {
const form = this.element;
const text = form.querySelector("textarea[name='text']").value;
const type = form.querySelector("select[name='type']").value;
if (!text) {
ui.notifications.warn("Please enter text to import.");
return;
}
try {
let data = [];
if (type === "adversary") {
data = DHImporter.parseAdversaries(text);
} else if (type === "environment") {
data = DHImporter.parseEnvironments(text);
}
if (data.length === 0) {
ui.notifications.warn("No valid statblocks found.");
return;
}
// Check existing features
this.parsedData = await DHImporter.checkExistingFeatures(data);
this.parsedDataType = type;
this.step = "preview";
this.render(true);
} catch (e) {
console.error(e);
ui.notifications.error("Error parsing data. Check console for details.");
}
}
static async onImport(event, target) {
try {
const formData = new FormDataExtended(this.element).object;
for (const actor of this.parsedData) {
actor.useFeatures = {};
for (const item of actor.items) {
const key = `useFeatures.${actor.name}.${item.name}`;
if (formData[key]) {
actor.useFeatures[item.name] = formData[key];
}
}
}
await DHImporter.createDocuments(this.parsedData, this.parsedDataType);
ui.notifications.info(`Successfully imported ${this.parsedData.length} documents.`);
this.close();
} catch (e) {
console.error(e);
ui.notifications.error("Error importing data.");
}
}
static onBack(event, target) {
this.step = "input";
this.render(true);
}
}

469
scripts/importer.js Normal file
View file

@ -0,0 +1,469 @@
export class DHImporter {
/**
* Parse Adversary Text
* @param {string} text
* @returns {Array<Object>} Array of adversary data objects
*/
static parseAdversaries(text) {
const blocks = text.split(/^-----$/gm).filter(b => b.trim());
const results = [];
for (let block of blocks) {
const lines = block.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l);
if (lines.length === 0) continue;
try {
const data = {
name: "Unknown Adversary",
type: "adversary",
system: {
tier: 1,
type: "standard",
resources: { hitPoints: { value: 0, max: 0 }, stress: { value: 0, max: 0 } },
damageThresholds: { major: 0, severe: 0 },
attack: {
name: "Attack",
type: "attack",
roll: { type: "attack", bonus: 0 },
damage: { parts: [] }
},
experiences: {},
motivesAndTactics: ""
},
items: []
};
let currentSection = "header";
let featureBuffer = null;
let lineIndex = 0;
// --- Header Parsing ---
// Name is line 0
if (lines.length > 0) {
data.name = lines[0];
lineIndex = 1;
}
while (lineIndex < lines.length) {
let line = lines[lineIndex];
// --- Section Detection ---
if (line.match(/^Motives & Tactics[:]?$/i)) {
currentSection = "motives";
lineIndex++;
continue;
} else if (line.match(/^Features[:]?$/i)) {
currentSection = "features";
lineIndex++;
continue;
} else if (line.match(/^HP & Stress[:]?$/i)) {
currentSection = "hp_stress";
lineIndex++;
continue;
} else if (line.match(/^Experience[:]?$/i)) {
currentSection = "experience";
lineIndex++;
// Check if the rest of the line has content (old format)
const inlineExp = line.replace(/^Experience[:]?\s*/i, "");
if (inlineExp) {
DHImporter._parseExperienceLine(inlineExp, data);
}
continue;
}
// --- Section Processing ---
// HEADER / STATS (Before any specific section)
if (currentSection === "header") {
if (line.match(/Tier\s+(\d+)\s+(\w+)/i)) {
const match = line.match(/Tier\s+(\d+)\s+(\w+)/i);
data.system.tier = parseInt(match[1]);
data.system.type = match[2].toLowerCase();
}
else if (line.match(/^Difficulty:/i)) {
// Can be "Difficulty: 14" or "Difficulty: 11 | Thresholds: ..."
const valueMatch = line.match(/Difficulty:\s*(\d+)/i);
if (valueMatch) data.system.difficulty = parseInt(valueMatch[1]);
// Check for inline stuff (Old Format)
if (line.includes("|")) {
const parts = line.split("|").map(p => p.trim());
for (const part of parts) {
if (part.startsWith("HP:")) {
data.system.resources.hitPoints.max = parseInt(part.split(":")[1]);
} else if (part.startsWith("Stress:")) {
data.system.resources.stress.max = parseInt(part.split(":")[1]);
} else if (part.startsWith("Thresholds:")) {
const th = part.split(":")[1].split("/").map(t => parseInt(t.trim()));
if (th[0]) data.system.damageThresholds.major = th[0];
if (th[1]) data.system.damageThresholds.severe = th[1];
}
}
}
}
else if (line.match(/^(Attack:|ATK:)/i)) {
// "Attack: +1" or "ATK: +2 | Claws: Melee | ..."
const bonusMatch = line.match(/(?:Attack|ATK):\s*([+-]?\d+)/i);
if (bonusMatch) data.system.attack.roll.bonus = parseInt(bonusMatch[1]);
// If split format e.g. "Attack: +1" and next line is "Claws: Melee..."
if (!line.includes("|") && lineIndex + 1 < lines.length) {
const nextLine = lines[lineIndex + 1];
if (nextLine.includes(":") && !nextLine.match(/^(Experience|Features|Motives|Difficulty)/)) {
// Likely the weapon details: "Claws: Melee | 1d8+3 phy"
DHImporter._parseAttackDetails(nextLine, data);
lineIndex++; // Consume next line
}
} else if (line.includes("|")) {
// Inline formatting
const parts = line.split("|").slice(1).join("|"); // remove ATK part
DHImporter._parseAttackDetails(parts, data);
}
}
else if (line.match(/^Experience:/i)) {
// Handled by section detection usually, but if missed:
const inlineExp = line.replace(/^Experience:\s*/i, "");
if (inlineExp && inlineExp.trim() !== "—") {
DHImporter._parseExperienceLine(inlineExp, data);
}
// If empty, next lines will be picked up by 'experience' section logic if we switched?
// Actually, if we are here, we might have skipped section detect if regex didn't match perfectly.
}
else {
// Description text usually
if (!data.system.notes) data.system.notes = "";
data.system.notes += line + "<br>";
}
}
// MOTIVES
else if (currentSection === "motives") {
data.system.motivesAndTactics += line + " ";
}
// EXPERIENCE (Multi-line)
else if (currentSection === "experience") {
// "Ambusher +3"
DHImporter._parseExperienceLine(line, data);
}
// HP & STRESS (Vertical)
else if (currentSection === "hp_stress") {
// Heuristic parsing for the copy-paste mess
// Look for numbers that might be thresholds
if (line.match(/^\d+$/)) {
const val = parseInt(line);
// Context matters.
// If previous line was "minor" or "1 HP" (implied minor), val is minor.
// If previous line was "HP:" or "STRESS:", val is that.
const prevLine = lines[lineIndex - 1] || "";
const prevPrevLine = lines[lineIndex - 2] || "";
if (prevLine.match(/HP:/i)) {
data.system.resources.hitPoints.max = val;
} else if (prevLine.match(/STRESS:/i)) {
data.system.resources.stress.max = val;
} else if (prevLine.includes("minor") || prevPrevLine.includes("minor")) {
data.system.damageThresholds.minor = val; // System doesn't have minor usually?
// Wait, Daggerheart system model: major, severe. Minor is usually just 1 HP.
// But let's check input: "minor \n 1 HP \n 9". 9 is likely the threshold.
// However, data.system.damageThresholds usually only stores Major/Severe in Foundry system?
// Let's assume we ignore minor or map it if valid.
} else if (prevLine.includes("major") || prevPrevLine.includes("major")) {
data.system.damageThresholds.major = val;
} else if (prevLine.includes("severe") || prevPrevLine.includes("severe")) {
data.system.damageThresholds.severe = val;
} else if (prevLine.includes("HP")) {
// "1 HP" / "2 HP" lines often precede thresholds
// If we see "9" after "1 HP", check if "minor" was before that.
if (prevPrevLine.includes("minor")) { /* ignore or store */ }
if (prevPrevLine.includes("major")) data.system.damageThresholds.major = val;
if (prevPrevLine.includes("severe")) data.system.damageThresholds.severe = val;
}
}
}
// FEATURES
else if (currentSection === "features") {
const featureMatch = line.match(/^(.+?)\s+[-]\s+(\w+)(?::\s*(.*))?$/);
// "Name - Type" (Description on next line) OR "Name - Type: Description"
if (featureMatch) {
if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer));
featureBuffer = {
name: featureMatch[1].trim(),
typeHint: featureMatch[2].trim(),
description: featureMatch[3] || ""
};
} else if (featureBuffer) {
featureBuffer.description += (featureBuffer.description ? " " : "") + line;
}
}
lineIndex++;
}
// Flush feature
if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer));
results.push(data);
} catch (e) {
console.error("Failed to parse adversary block", e, block);
}
}
return results;
}
static _parseAttackDetails(line, data) {
// Claws: Melee | 1d8+3 phy OR Claws: Melee 1d8+3 phy
// Simple heuristic splitting
let parts = line.split("|").map(p => p.trim());
if (parts.length === 1) {
// Try splitting by : for name/range
const firstColon = line.indexOf(":");
if (firstColon > -1) {
const name = line.substring(0, firstColon).trim();
const rest = line.substring(firstColon + 1).trim();
// "Melee 1d8+3 phy" -> split by first space? or known ranges?
// Heuristic: Range is usually one word (Melee, Close, Far)
const rangeMatch = rest.match(/^(\w+)\s+(.*)$/);
if (rangeMatch) {
parts = [name + ":" + rangeMatch[1], rangeMatch[2]];
} else {
parts = [line];
}
}
}
// Now parse parts roughly matching old logic
// Part 0: Name: Range
if (parts.length > 0) {
const nameRange = parts[0].split(":");
data.system.attack.name = nameRange[0].trim();
if (nameRange[1]) data.system.attack.range = nameRange[1].trim().toLowerCase();
}
// Part 1: Damage
if (parts.length > 1) {
const dmgStr = parts[1];
const dmgMatch = dmgStr.match(/^(.+?)\s+(\w+)$/);
let formula = dmgStr;
let type = "physical";
if (dmgMatch) {
formula = dmgMatch[1];
type = dmgMatch[2].toLowerCase();
// Map short codes
if (type === "phy") type = "physical";
else if (type === "mag") type = "magic";
}
data.system.attack.damage.parts.push({
type: [type],
applyTo: "hitPoints",
value: {
custom: { enabled: true, formula: formula },
multiplier: "flat", dice: "d6"
}
});
}
}
static _parseExperienceLine(line, data) {
// "Ambusher +3"
const match = line.match(/(.+?)\s+([+-]?\d+)$/);
if (match) {
const currentCount = Object.keys(data.system.experiences).length;
const key = "exp" + currentCount;
data.system.experiences[key] = {
name: match[1].trim(),
value: parseInt(match[2])
};
}
}
/**
* Helper to create feature item data
*/
static _createFeatureItem(buffer) {
// In the future, we could map "Action" vs "Passive" to system fields if available.
// For now, Daggerheart features are mostly just text descriptions,
// sometimes with specific actions embedded.
// We will put the type in the description.
return {
name: buffer.name,
type: "feature",
system: {
description: `<p><strong>${buffer.typeHint}</strong>: ${buffer.description}</p>`
}
};
}
/**
* Parse Environment Text
* @param {string} text
* @returns {Array<Object>}
*/
static parseEnvironments(text) {
const blocks = text.split(/^-----$/gm).filter(b => b.trim());
const results = [];
for (let block of blocks) {
const lines = block.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l);
if (lines.length === 0) continue;
try {
const data = {
name: lines[0],
type: "environment",
system: {
tier: 1,
type: "exploration",
difficulty: 12,
impulses: "",
potentialAdversaries: { label: "" },
notes: ""
},
items: []
};
let lineIndex = 1;
// Tier 1 Exploration
if (lineIndex < lines.length) {
const tierMatch = lines[lineIndex].match(/Tier\s+(\d+)\s+(\w+)/i);
if (tierMatch) {
data.system.tier = parseInt(tierMatch[1]);
data.system.type = tierMatch[2].toLowerCase();
lineIndex++;
}
}
// Description
let description = [];
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (line.match(/^(Impulses|Difficulty|Potential Adversaries|Features)/i)) {
break;
}
description.push(line);
lineIndex++;
}
data.system.notes = description.join("<br>");
let currentSection = "header";
let featureBuffer = null;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (line.match(/^Impulses:/i)) {
data.system.impulses = line.replace(/^Impulses:\s*/i, "").trim();
} else if (line.match(/^Difficulty:/i)) {
data.system.difficulty = parseInt(line.split(":")[1].trim());
} else if (line.match(/^Potential Adversaries:/i)) {
data.system.potentialAdversaries.label = line.replace(/^Potential Adversaries:\s*/i, "").trim();
} else if (line.match(/^Features/i)) {
currentSection = "features";
} else if (currentSection === "features") {
// Parsing Features: "Name - Type: Description"
const featureMatch = line.match(/^(.+?)\s+[-]\s+(\w+):\s*(.*)/);
if (featureMatch) {
if (featureBuffer) {
data.items.push(DHImporter._createFeatureItem(featureBuffer));
}
featureBuffer = {
name: featureMatch[1],
typeHint: featureMatch[2],
description: featureMatch[3]
};
} else if (featureBuffer) {
featureBuffer.description += "<br>" + line;
}
}
lineIndex++;
}
if (featureBuffer) {
data.items.push(DHImporter._createFeatureItem(featureBuffer));
}
results.push(data);
} catch (e) {
console.error("Failed to parse environment block", e);
}
}
return results;
}
static async checkExistingFeatures(dataList) {
if (!dataList || dataList.length === 0) return dataList;
const results = [];
// Pre-fetch world packs content index for speed, or just search relevant packs
// For simplicity, we search all packs
const packs = game.packs.filter(p => p.documentName === "Item");
for (const actorData of dataList) {
const enrichedActor = { ...actorData, foundFeatures: {} };
for (const item of actorData.items) {
if (item.type !== "feature") continue;
// Search for item by name
let found = null;
for (const pack of packs) {
const index = pack.index.size > 0 ? pack.index : await pack.getIndex();
const entry = index.find(i => i.name === item.name);
if (entry) {
found = {
uuid: entry.uuid,
name: entry.name,
pack: pack.metadata.label
};
break;
}
}
if (found) {
enrichedActor.foundFeatures[item.name] = found;
}
}
results.push(enrichedActor);
}
return results;
}
static async createDocuments(dataList, type) {
if (!dataList || dataList.length === 0) return;
const Actor = getDocumentClass("Actor");
const actorsToCreate = [];
for (const data of dataList) {
// Process Items based on 'useCompendium' flags or similar set by the UI
// The UI should have modified data.items or we loop through and replace based on user choice
const finalItems = [];
for (const item of data.items) {
if (data.useFeatures && data.useFeatures[item.name]) {
// User selected to use existing feature
const uuid = data.useFeatures[item.name];
const original = await fromUuid(uuid);
if (original) {
finalItems.push(original.toObject());
} else {
finalItems.push(item);
}
} else {
finalItems.push(item);
}
}
data.items = finalItems;
// Clean up temporary flags
delete data.foundFeatures;
delete data.useFeatures;
actorsToCreate.push(data);
}
await Actor.createDocuments(actorsToCreate);
}
}

18
scripts/module.js Normal file
View file

@ -0,0 +1,18 @@
import { DHImporterApp } from "./app.js";
Hooks.once("init", async () => {
await loadTemplates([
"modules/dh-importer/templates/preview.hbs"
]);
});
Hooks.on("renderActorDirectory", (app, html, data) => {
const $html = $(html);
const button = $(`<button class="dh-importer-btn"><i class="fas fa-file-import"></i> Import Statblocks</button>`);
button.on("click", () => {
new DHImporterApp().render({ force: true });
});
$html.find(".directory-header .header-actions").append(button);
});