commit 4ebe86ee8cdf6f53ec78188cb67ebf81bd0ea3da Author: CPTN Cosmo Date: Fri Jan 23 20:06:09 2026 +0100 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..aad98aa --- /dev/null +++ b/README.md @@ -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. + + diff --git a/module.json b/module.json new file mode 100644 index 0000000..36e07bf --- /dev/null +++ b/module.json @@ -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" +} \ No newline at end of file diff --git a/scripts/app.js b/scripts/app.js new file mode 100644 index 0000000..e8b2543 --- /dev/null +++ b/scripts/app.js @@ -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); + } +} diff --git a/scripts/importer.js b/scripts/importer.js new file mode 100644 index 0000000..651158e --- /dev/null +++ b/scripts/importer.js @@ -0,0 +1,469 @@ +export class DHImporter { + /** + * Parse Adversary Text + * @param {string} text + * @returns {Array} 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 + "
"; + } + } + + // 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: `

${buffer.typeHint}: ${buffer.description}

` + } + }; + } + + /** + * Parse Environment Text + * @param {string} text + * @returns {Array} + */ + 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("
"); + + 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 += "
" + 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); + } +} diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..1e4de3a --- /dev/null +++ b/scripts/module.js @@ -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.on("click", () => { + new DHImporterApp().render({ force: true }); + }); + + $html.find(".directory-header .header-actions").append(button); +}); diff --git a/styles/importer.css b/styles/importer.css new file mode 100644 index 0000000..325ef85 --- /dev/null +++ b/styles/importer.css @@ -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; +} \ No newline at end of file diff --git a/templates/importer.hbs b/templates/importer.hbs new file mode 100644 index 0000000..833799c --- /dev/null +++ b/templates/importer.hbs @@ -0,0 +1,27 @@ +
+ + + {{#if isInput}} +
+ + +
+ +
+ + +
+ + + {{/if}} + + {{#if isPreview}} + {{> "modules/dh-importer/templates/preview.hbs"}} + {{/if}} +
\ No newline at end of file diff --git a/templates/preview.hbs b/templates/preview.hbs new file mode 100644 index 0000000..6bebe31 --- /dev/null +++ b/templates/preview.hbs @@ -0,0 +1,51 @@ +
+
+ {{#each parsedData as |actor idx|}} +
+

+ {{actor.name}} + Tier {{actor.system.tier}} {{actor.system.type}} +

+
+ {{#if actor.system.resources.hitPoints.max}}HP: + {{actor.system.resources.hitPoints.max}}{{/if}} + {{#if actor.system.resources.stress.max}}Stress: + {{actor.system.resources.stress.max}}{{/if}} + Difficulty: {{actor.system.difficulty}} +
+ +
+

Features

+
    + {{#each actor.items as |item itemIdx|}} + {{#if (eq item.type "feature")}} +
  • + {{item.name}} + + {{#if (lookup ../foundFeatures item.name)}} +
    + +
    + {{else}} + New + {{/if}} + +
    {{{item.system.description}}}
    +
  • + {{/if}} + {{/each}} +
+
+
+ {{/each}} +
+ + +
\ No newline at end of file