initial commit
This commit is contained in:
commit
4ebe86ee8c
8 changed files with 1006 additions and 0 deletions
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Daggerheart Importer
|
||||||
|
|
||||||
|
A Foundry VTT module for the **Daggerheart** system that allows you to easily import Adversaries and Environments from text statblocks (e.g., from PDFs or simple text files).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Bulk Import**: Paste multiple statblocks at once.
|
||||||
|
- **Smart Parsing**: Automatically detects sections for Stats, Attacks, Features, Experiences, and Motives.
|
||||||
|
- **Vertical & Columnar Support**: Handles various copy-paste formats including vertical "HP & Stress" columns.
|
||||||
|
- **Compendium Matching**: Automatically scans your world compendiums to match Features by name, allowing you to link to existing data instead of creating duplicates.
|
||||||
|
- **Preview UI**: Review parsed data before importing. Toggle found features on or off.
|
||||||
|
- **Daggerheart Styling**: A clean, themed interface matching the system aesthetics.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Open the **Actor Directory** tab in Foundry VTT.
|
||||||
|
2. Click the **"Import Statblocks"** button in the header.
|
||||||
|
3. Choose **Adversary** or **Environment** from the dropdown.
|
||||||
|
4. Paste your text into the box. Separate multiple blocks with `-----`.
|
||||||
|
5. Click **Review Data**.
|
||||||
|
6. Verify the parsing in the preview window.
|
||||||
|
7. Click **Confirm Import**.
|
||||||
|
|
||||||
|
## Copy-Paste Tips
|
||||||
|
|
||||||
|
The parser is designed to be robust, but for best results:
|
||||||
|
- Ensure the **Name** is on the first line.
|
||||||
|
- Ensure **Tier X [Type]** is on the second line (e.g., "Tier 1 Standard").
|
||||||
|
- Keep headers like **Features**, **Experience**, and **Motives & Tactics** on their own lines if possible.
|
||||||
|
|
||||||
|
|
||||||
39
module.json
Normal file
39
module.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"id": "dh-importer",
|
||||||
|
"title": "Daggerheart Importer",
|
||||||
|
"description": "Imports Adversaries and Environments from text blocks into the Daggerheart system.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"compatibility": {
|
||||||
|
"minimum": "13",
|
||||||
|
"verified": "13"
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "CPTN Cosmo",
|
||||||
|
"email": "cptncosmo@gmail.com",
|
||||||
|
"url": "https://github.com/cptn-cosmo",
|
||||||
|
"discord": "cptn_cosmo",
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relationships": {
|
||||||
|
"systems": [
|
||||||
|
{
|
||||||
|
"id": "daggerheart",
|
||||||
|
"type": "system",
|
||||||
|
"manifest": "",
|
||||||
|
"compatibility": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"esmodules": [
|
||||||
|
"scripts/module.js"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"styles/importer.css"
|
||||||
|
],
|
||||||
|
"description": "Imports Adversaries and Environments from text blocks into the Daggerheart system.",
|
||||||
|
"url": "https://github.com/cptn-cosmo/dh-importer",
|
||||||
|
"manifest": "https://git.geeks.gay/cosmo/dh-importer/raw/branch/main/module.json",
|
||||||
|
"download": "https://git.geeks.gay/cosmo/dh-importer/releases/download/1.0.0/dh-importer.zip"
|
||||||
|
}
|
||||||
114
scripts/app.js
Normal file
114
scripts/app.js
Normal 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
469
scripts/importer.js
Normal 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
18
scripts/module.js
Normal 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);
|
||||||
|
});
|
||||||
257
styles/importer.css
Normal file
257
styles/importer.css
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
.dh-importer-app .window-content {
|
||||||
|
background: url("../../../systems/daggerheart/assets/backgrounds/daggerheart_bg_dark.webp"), #1a1a1a;
|
||||||
|
background-size: cover;
|
||||||
|
color: #e0d0b0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Prevent window scrolling, force internal scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app form {
|
||||||
|
padding: 5px;
|
||||||
|
/* Reduced padding for wider content */
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app h2 {
|
||||||
|
border-bottom: 2px solid #cbb484;
|
||||||
|
color: #cbb484;
|
||||||
|
font-family: "Modesto Condensed", serif;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
/* Reduced space */
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app .form-group {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 5px 0;
|
||||||
|
/* Reduced space */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app textarea {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid #7a6a4a;
|
||||||
|
color: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "Fira Code", monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* removed min-height to allow flex shrinking/growing without pushing buttons off */
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: none;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #cbb484;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-family: "Modesto Condensed", serif;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app select {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #7a6a4a;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app button {
|
||||||
|
background: linear-gradient(180deg, #5b1c1c 0%, #3a0e0e 100%);
|
||||||
|
border: 1px solid #7a6a4a;
|
||||||
|
color: #ffd700;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: "Modesto Condensed", serif;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app button:hover {
|
||||||
|
background: linear-gradient(180deg, #7a2828 0%, #561414 100%);
|
||||||
|
box-shadow: 0 0 8px #cbb484;
|
||||||
|
text-shadow: 0 0 5px #ffd700;
|
||||||
|
border-color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app button i {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Styles */
|
||||||
|
.dh-importer-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 10px;
|
||||||
|
/* Add space before preview */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .actor-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .actor-preview {
|
||||||
|
background: rgba(20, 20, 20, 0.8);
|
||||||
|
border: 1px solid #7a6a4a;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview h3 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Stack Name and Tier */
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
/* Space after name/tier */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview h3 small {
|
||||||
|
font-size: 0.7em;
|
||||||
|
/* Slightly larger */
|
||||||
|
color: #aaa;
|
||||||
|
margin-left: 0;
|
||||||
|
/* Reset margin since it's stacked */
|
||||||
|
margin-top: 5px;
|
||||||
|
/* Space between name and tier */
|
||||||
|
font-family: sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
/* Increased spacing */
|
||||||
|
margin-bottom: 30px;
|
||||||
|
/* More space before features */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .stats span {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 8px 16px;
|
||||||
|
/* Increased padding */
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1.1em;
|
||||||
|
/* Larger text */
|
||||||
|
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .stats span strong {
|
||||||
|
color: #e0d0b0;
|
||||||
|
/* Gold color for labels */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .features-list h4 {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 1em;
|
||||||
|
/* Small header */
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-top: 20px;
|
||||||
|
/* Whitespace before */
|
||||||
|
margin-bottom: 15px;
|
||||||
|
/* Whitespace after */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .features-list ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .feature-item {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-left: 3px solid #7a6a4a;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .feature-item strong {
|
||||||
|
color: #e0d0b0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .feature-match {
|
||||||
|
background: rgba(46, 139, 87, 0.2);
|
||||||
|
border: 1px solid rgba(46, 139, 87, 0.4);
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .feature-match input {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .new-tag {
|
||||||
|
background: #b8860b;
|
||||||
|
color: #000;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-preview .desc {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-importer-app .action-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
27
templates/importer.hbs
Normal file
27
templates/importer.hbs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<div class="dh-wrapper">
|
||||||
|
|
||||||
|
|
||||||
|
{{#if isInput}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Import Type</label>
|
||||||
|
<select name="type">
|
||||||
|
{{#each types as |label key|}}
|
||||||
|
<option value="{{key}}">{{label}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="flex: 1;">
|
||||||
|
<label>Paste Statblocks</label>
|
||||||
|
<textarea name="text" placeholder="Paste data here..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-footer">
|
||||||
|
<button type="button" data-action="parse"><i class="fas fa-search"></i> Review Data</button>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isPreview}}
|
||||||
|
{{> "modules/dh-importer/templates/preview.hbs"}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
51
templates/preview.hbs
Normal file
51
templates/preview.hbs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<div class="dh-importer-preview">
|
||||||
|
<div class="actor-list">
|
||||||
|
{{#each parsedData as |actor idx|}}
|
||||||
|
<div class="actor-preview">
|
||||||
|
<h3>
|
||||||
|
<span>{{actor.name}}</span>
|
||||||
|
<small>Tier {{actor.system.tier}} {{actor.system.type}}</small>
|
||||||
|
</h3>
|
||||||
|
<div class="stats">
|
||||||
|
{{#if actor.system.resources.hitPoints.max}}<span><strong>HP:</strong>
|
||||||
|
{{actor.system.resources.hitPoints.max}}</span>{{/if}}
|
||||||
|
{{#if actor.system.resources.stress.max}}<span><strong>Stress:</strong>
|
||||||
|
{{actor.system.resources.stress.max}}</span>{{/if}}
|
||||||
|
<span><strong>Difficulty:</strong> {{actor.system.difficulty}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-list">
|
||||||
|
<h4>Features</h4>
|
||||||
|
<ul>
|
||||||
|
{{#each actor.items as |item itemIdx|}}
|
||||||
|
{{#if (eq item.type "feature")}}
|
||||||
|
<li class="feature-item">
|
||||||
|
<strong>{{item.name}}</strong>
|
||||||
|
|
||||||
|
{{#if (lookup ../foundFeatures item.name)}}
|
||||||
|
<div class="feature-match">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="useFeatures.{{actor.name}}.{{item.name}}"
|
||||||
|
value="{{lookup (lookup ../foundFeatures item.name) 'uuid'}}" checked>
|
||||||
|
Use found in <strong>{{lookup (lookup ../foundFeatures item.name) 'pack'}}</strong>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<span class="new-tag">New</span>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="desc">{{{item.system.description}}}</div>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-footer">
|
||||||
|
<button type="button" data-action="back"><i class="fas fa-arrow-left"></i> Back</button>
|
||||||
|
<button type="button" data-action="import"><i class="fas fa-file-import"></i> Confirm Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue