diff --git a/module.json b/module.json index a68e00d..9caf802 100644 --- a/module.json +++ b/module.json @@ -1,7 +1,7 @@ { "id": "dh-importer", - "title": "Daggerheart Importer", - "version": "1.0.0", + "title": "Daggerheart Statblock Importer", + "version": "1.2.2", "compatibility": { "minimum": "13", "verified": "13" @@ -34,5 +34,5 @@ "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" + "download": "https://git.geeks.gay/cosmo/dh-importer/releases/download/1.2.2/dh-importer.zip" } \ No newline at end of file diff --git a/scripts/app.js b/scripts/app.js index e8b2543..466ac48 100644 --- a/scripts/app.js +++ b/scripts/app.js @@ -8,6 +8,7 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) { this.step = "input"; this.parsedData = null; this.parsedDataType = null; + this.inputText = ""; } static DEFAULT_OPTIONS = { @@ -45,15 +46,42 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) { types: { adversary: "Adversary", environment: "Environment" - } + }, + inputText: this.inputText, + folders: game.folders.filter(f => f.type === "Actor").sort((a, b) => a.name.localeCompare(b.name)), + packs: game.packs.filter(p => p.documentName === "Actor" && !p.locked) }; } + _onRender(context, options) { + super._onRender(context, options); + + // Bind File Pickers + this.element.querySelectorAll("img[data-edit]").forEach(img => { + img.addEventListener("click", ev => { + const field = img.dataset.edit; + const fp = new FilePicker({ + type: "image", + current: img.getAttribute("src"), + callback: path => { + img.src = path; + const input = this.element.querySelector(`input[name='${field}']`); + if (input) input.value = path; + } + }); + fp.browse(); + }); + }); + } + 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; + // Store input text + this.inputText = text; + if (!text) { ui.notifications.warn("Please enter text to import."); return; @@ -86,19 +114,50 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) { static async onImport(event, target) { try { - const formData = new FormDataExtended(this.element).object; + const formData = new foundry.applications.ux.FormDataExtended(this.element).object; for (const actor of this.parsedData) { + // Update Actor Image + if (formData[`img.${actor.name}`]) { + actor.img = formData[`img.${actor.name}`]; + actor.prototypeToken.texture.src = formData[`img.${actor.name}`]; + } + + // Update Attack Image + if (formData[`attackImg.${actor.name}`]) { + actor.system.attack.img = formData[`attackImg.${actor.name}`]; + } + + // Open Sheet Flag + if (formData[`openSheet.${actor.name}`]) { + actor.openSheet = true; + } + actor.useFeatures = {}; for (const item of actor.items) { const key = `useFeatures.${actor.name}.${item.name}`; if (formData[key]) { actor.useFeatures[item.name] = formData[key]; } + + // Update Item Image + const itemImgKey = `itemImg.${actor.name}.${item.name}`; + if (formData[itemImgKey]) { + item.img = formData[itemImgKey]; + // Also update embedded action image if present + if (item.system.actions) { + for (const actionId in item.system.actions) { + item.system.actions[actionId].img = formData[itemImgKey]; + } + } + } } } - await DHImporter.createDocuments(this.parsedData, this.parsedDataType); + const importTarget = formData.importTarget || ""; + const sortTier = formData.sortTier || false; + + await DHImporter.createDocuments(this.parsedData, this.parsedDataType, { target: importTarget, sortTier }); ui.notifications.info(`Successfully imported ${this.parsedData.length} documents.`); this.close(); } catch (e) { diff --git a/scripts/importer.js b/scripts/importer.js index 651158e..58b97b6 100644 --- a/scripts/importer.js +++ b/scripts/importer.js @@ -16,6 +16,8 @@ export class DHImporter { const data = { name: "Unknown Adversary", type: "adversary", + img: "systems/daggerheart/assets/icons/documents/actors/dragon-head.svg", + prototypeToken: { texture: { src: "systems/daggerheart/assets/icons/documents/actors/dragon-head.svg" } }, system: { tier: 1, type: "standard", @@ -81,43 +83,53 @@ export class DHImporter { 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]); + // Can be "Difficulty: 14" or "Difficulty: 11 | Thresholds: ..." or "Difficulty: 12Thresholds: 5/9HP: 4Stress: 3" - // Check for inline stuff (Old Format) + // Robust Regex Extraction + const diffMatch = line.match(/Difficulty:\s*(\d+)/i); + if (diffMatch) data.system.difficulty = parseInt(diffMatch[1]); + + const thMatch = line.match(/Thresholds:\s*(\d+)\/(\d+)/i); + if (thMatch) { + data.system.damageThresholds.major = parseInt(thMatch[1]); + data.system.damageThresholds.severe = parseInt(thMatch[2]); + } + + const hpMatch = line.match(/HP:\s*(\d+)/i); + if (hpMatch) data.system.resources.hitPoints.max = parseInt(hpMatch[1]); + + const stressMatch = line.match(/Stress:\s*(\d+)/i); + if (stressMatch) data.system.resources.stress.max = parseInt(stressMatch[1]); + + // Check for inline stuff (Old Format with Pipes) 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]; - } + // Fallback for pipes if regex somehow missed or overwrote (unlikely but safe) + // Actually the regex above is better. } } } else if (line.match(/^(Attack:|ATK:)/i)) { - // "Attack: +1" or "ATK: +2 | Claws: Melee | ..." + // "Attack: +1" or "ATK: +2 | Claws: Melee | ..." or "ATK: +2Claws: Melee1d6+2 phy" const bonusMatch = line.match(/(?:Attack|ATK):\s*([+-]?\d+)/i); if (bonusMatch) data.system.attack.roll.bonus = parseInt(bonusMatch[1]); + // Clean the line to remove "ATK: +2" part + let restOfLine = line.replace(/(?:Attack|ATK):\s*[+-]?\d+/, "").trim(); + // If split format e.g. "Attack: +1" and next line is "Claws: Melee..." - if (!line.includes("|") && lineIndex + 1 < lines.length) { + if ((!restOfLine || restOfLine.length === 0) && 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 (restOfLine.length > 0) { + // Inline formatting (smashed or piped) + if (restOfLine.startsWith("|")) restOfLine = restOfLine.substring(1).trim(); + DHImporter._parseAttackDetails(restOfLine, data); } } else if (line.match(/^Experience:/i)) { @@ -165,18 +177,12 @@ export class DHImporter { } 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. + data.system.damageThresholds.minor = val; } 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; @@ -186,8 +192,9 @@ export class DHImporter { // FEATURES else if (currentSection === "features") { - const featureMatch = line.match(/^(.+?)\s+[–-]\s+(\w+)(?::\s*(.*))?$/); // "Name - Type" (Description on next line) OR "Name - Type: Description" + // Supports hyphen (-), en-dash (–), em-dash (—) and optional surrounding spaces + const featureMatch = line.match(/^(.+?)\s*[–—-]\s*(\w+)(?::\s*(.*))?$/); if (featureMatch) { if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer)); @@ -216,69 +223,87 @@ export class DHImporter { } 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]; - } - } + // Claws: Melee | 1d8+3 phy OR Claws: Melee 1d8+3 phy OR Claws: Melee1d6+2 phy + + // 1. Extract Name + let name = "Attack"; + let rest = line; + + if (line.includes(":")) { + const parts = line.split(":"); + name = parts[0].trim(); + rest = parts.slice(1).join(":").trim(); } - // 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(); + data.system.attack.name = name; + + // 2. Extract Range + // Ranges: Melee, Very Close, Close, Far, Very Far + const rangeMatch = rest.match(/^(Melee|Very Close|Close|Far|Very Far)/i); + if (rangeMatch) { + let rangeVal = rangeMatch[1].toLowerCase(); + if (rangeVal === "very close") rangeVal = "veryClose"; + if (rangeVal === "very far") rangeVal = "veryFar"; + data.system.attack.range = rangeVal; + rest = rest.substring(rangeMatch[0].length).trim(); } - // Part 1: Damage - if (parts.length > 1) { - const dmgStr = parts[1]; - const dmgMatch = dmgStr.match(/^(.+?)\s+(\w+)$/); - let formula = dmgStr; + + // 3. Extract Damage + // Formula + Type + if (rest) { + // Remove optional pipes + rest = rest.replace(/^\|\s*/, ""); + + // "1d6+2 phy" + const dmgMatch = rest.match(/^(.+?)\s+(\w+)$/); + let formula = rest; 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"; + else if (type === "mag" || type === "magic") type = "magical"; } + data.system.attack.damage.parts.push({ type: [type], applyTo: "hitPoints", value: { custom: { enabled: true, formula: formula }, - multiplier: "flat", dice: "d6" + multiplier: "flat", dice: "d6", + flatMultiplier: 1 } }); } } 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]) - }; + // Handle "Experience: Manipulate +2, Infiltrate +2" or "Knowledge (Arcana, History) +2" + const parts = line.split(","); + let buffer = ""; + + for (let part of parts) { + buffer = buffer ? buffer + "," + part : part; + + // Check if buffer matches "Name +Value" + // We trim to handle spaces around commas + const trimmed = buffer.trim(); + // Regex to match "Name +Value" at the end of the string + const match = trimmed.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]) + }; + // Reset buffer as we found a complete experience + buffer = ""; + } } + // If buffer remains (e.g. "Some Trait" without number), it is ignored, consistent with previous behavior. } /** @@ -290,13 +315,115 @@ export class DHImporter { // sometimes with specific actions embedded. // We will put the type in the description. - return { + const isAction = buffer.typeHint?.toLowerCase() === "action"; + + const item = { name: buffer.name, type: "feature", + img: "icons/svg/item-bag.svg", system: { - description: `
${buffer.typeHint}: ${buffer.description}
` + description: `${buffer.typeHint}: ${buffer.description}
`, + actions: {}, + featureForm: isAction ? "action" : "passive" } }; + + if (isAction) { + // Determine if this is an attack or a generic ability + const isAttack = buffer.description.match(/make.*?attack/i) || + buffer.description.match(/(?:deal|inflict|take)\s+(\d+(?:d\d+)?(?:[\s]*[\+\-][\s]*\d+)?)\s+(direct\s+)?(\w+)\s+damage/i); + + const actionType = isAttack ? "attack" : "ability"; + const rollType = isAttack ? "attack" : "ability"; + + const actionId = foundry.utils.randomID(); + + const action = { + _id: actionId, + type: actionType, + name: buffer.name, + actionType: "action", + img: "icons/svg/item-bag.svg", + systemPath: "actions", + chatDisplay: true, + cost: [], + damage: { parts: [], includeBase: false, direct: false }, + range: "", + roll: { + type: rollType, + diceRolling: { multiplier: "flat", dice: "d6" } + } + }; + + // Parse Fear Cost + if (buffer.description.match(/^spen[md]\s+(?:a|1)\s+fear/i)) { + action.cost.push({ + consumeOnSuccess: false, + scalable: false, + key: "fear", + value: 1, + itemId: null, + step: null + }); + } + + // Parse Stress Cost + if (buffer.description.match(/mark\s+(?:a|1)\s+stress/i)) { + action.cost.push({ + consumeOnSuccess: false, + scalable: false, + key: "stress", + value: 1, + itemId: null, + step: null + }); + } + + // Parse Attack Trigger + if (buffer.description.match(/make.*?attack/i)) { + action.roll.type = "attack"; + } + + // Parse Range + const rangeMatch = buffer.description.match(/(Melee|Very Close|Close|Far|Very Far)/i); + if (rangeMatch) { + let rangeVal = rangeMatch[1].toLowerCase(); + if (rangeVal === "very close") rangeVal = "veryClose"; + if (rangeVal === "very far") rangeVal = "veryFar"; + + action.range = rangeVal; + } + + // Parse Damage: "deal 3d4+10 direct physical damage" + // Matches: context? + formula + optional direct + type + "damage" + const damageMatch = buffer.description.match(/(?:deal|inflict|take)\s+(\d+(?:d\d+)?(?:[\s]*[\+\-][\s]*\d+)?)\s+(direct\s+)?(\w+)\s+damage/i); + + if (damageMatch) { + // remove spaces in formula for cleaner data + let formula = damageMatch[1].replace(/\s/g, ""); + const isDirect = !!damageMatch[2]; // Captured "direct " + let type = damageMatch[3].toLowerCase(); + + // Map short codes + if (type === "phy") type = "physical"; + else if (type === "mag" || type === "magic") type = "magical"; + + action.damage.direct = isDirect; + action.damage.parts.push({ + value: { + custom: { enabled: true, formula: formula }, + multiplier: "flat", dice: "d6", + flatMultiplier: 1 // Default + }, + applyTo: "hitPoints", + type: [type] + }); + } + + item.system.actions[actionId] = action; + } + + return item; } /** @@ -316,6 +443,7 @@ export class DHImporter { const data = { name: lines[0], type: "environment", + img: "systems/daggerheart/assets/icons/documents/actors/forest.svg", system: { tier: 1, type: "exploration", @@ -365,7 +493,7 @@ export class DHImporter { currentSection = "features"; } else if (currentSection === "features") { // Parsing Features: "Name - Type: Description" - const featureMatch = line.match(/^(.+?)\s+[–-]\s+(\w+):\s*(.*)/); + const featureMatch = line.match(/^(.+?)\s*[–—-]\s*(\w+):\s*(.*)/); if (featureMatch) { if (featureBuffer) { data.items.push(DHImporter._createFeatureItem(featureBuffer)); @@ -431,19 +559,86 @@ export class DHImporter { return results; } - static async createDocuments(dataList, type) { + static async createDocuments(dataList, type, options = {}) { if (!dataList || dataList.length === 0) return; + const { target, sortTier } = options; const Actor = getDocumentClass("Actor"); + const Folder = getDocumentClass("Folder"); const actorsToCreate = []; + // Determine Target Context + let targetFolderId = null; + let targetPack = null; + + if (target) { + if (game.packs.has(target)) { + targetPack = game.packs.get(target); + } else { + targetFolderId = target; + } + } + + // Folder Cache for Tier Sorting + const folderCache = {}; + + const getTierFolder = async (tier) => { + const folderName = `Tier ${tier}`; + const cacheKey = `${targetPack ? targetPack.collection : "world"}-${targetFolderId || "root"}-${tier}`; + + if (folderCache[cacheKey]) return folderCache[cacheKey]; + + // Parent ID (for creating subfolder) + const parentId = targetFolderId; + + // Search for existing folder + let folder; + if (targetPack) { + // Search in compendium index (requires V11+ folders) + // We'll rely on pack.folders if available (V11) + if (targetPack.folders) { + folder = targetPack.folders.find(f => f.name === folderName && f.folder?.id === parentId); + } + } else { + // Search in World + folder = game.folders.find(f => f.type === "Actor" && f.name === folderName && f.folder?.id === parentId); + } + + if (folder) { + folderCache[cacheKey] = folder.id; + return folder.id; + } + + // Create Folder if not found + try { + const folderData = { + name: folderName, + type: "Actor", + folder: parentId, + sorting: "a" + }; + + if (targetPack) { + folderData.pack = targetPack.collection; + } + + const newFolder = await Folder.create(folderData, { pack: targetPack?.collection }); + if (newFolder) { + folderCache[cacheKey] = newFolder.id; + return newFolder.id; + } + } catch (e) { + console.warn("Could not create Tier folder:", e); + // Fallback to parent + return parentId; + } + }; + 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 + // Process Items 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) { @@ -457,13 +652,46 @@ export class DHImporter { } data.items = finalItems; + // Handle Sorting / Folder Assignment + if (sortTier) { + const tier = data.system.tier !== undefined ? data.system.tier : 0; + data.folder = await getTierFolder(tier); + } else if (targetFolderId) { + data.folder = targetFolderId; + } + // Clean up temporary flags + const shouldOpen = data.openSheet; delete data.foundFeatures; delete data.useFeatures; + delete data.openSheet; - actorsToCreate.push(data); + // If finding an existing actor to update could be a future feature, but for now we create new. + actorsToCreate.push({ data, shouldOpen }); } - await Actor.createDocuments(actorsToCreate); + let createdActors = []; + if (targetPack) { + // Import to Compendium + // Note: createDocuments with pack option works in V10+ + const docs = actorsToCreate.map(a => a.data); + createdActors = await Actor.createDocuments(docs, { pack: targetPack.collection }); + } else { + // Import to World + createdActors = await Actor.createDocuments(actorsToCreate.map(a => a.data)); + } + + // Open sheets + if (createdActors && createdActors.length > 0) { + for (let i = 0; i < createdActors.length; i++) { + // If we imported to compendium, we generally don't open sheets immediately as they are in the pack + // But if the user really wants to, we'd have to retrieve the sheet from the pack. + // Standard behavior for compendium import: don't open. + + if (!targetPack && actorsToCreate[i].shouldOpen) { + createdActors[i].sheet.render(true); + } + } + } } } diff --git a/styles/importer.css b/styles/importer.css index 325ef85..18c6afc 100644 --- a/styles/importer.css +++ b/styles/importer.css @@ -77,7 +77,7 @@ font-size: 1.1em; } -.dh-importer-app button { +.dh-importer-app .window-content button { background: linear-gradient(180deg, #5b1c1c 0%, #3a0e0e 100%); border: 1px solid #7a6a4a; color: #ffd700; @@ -90,14 +90,14 @@ transition: all 0.2s; } -.dh-importer-app button:hover { +.dh-importer-app .window-content 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 { +.dh-importer-app .window-content button i { margin-right: 5px; } @@ -254,4 +254,71 @@ gap: 10px; padding-top: 10px; border-top: 1px solid #4a4a4a; +} + +/* --- New Styles for Image Pickers --- */ + +.dh-importer-preview .header-row { + display: flex; + align-items: center; + gap: 15px; + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 10px; +} + +.dh-importer-preview .header-row h3 { + margin: 0; + border: none; + padding: 0; + flex: 1; +} + +.dh-importer-preview .image-picker { + position: relative; + cursor: pointer; + border: 1px solid #7a6a4a; + border-radius: 4px; + overflow: hidden; + transition: box-shadow 0.2s; + flex-shrink: 0; +} + +.dh-importer-preview .image-picker:hover { + box-shadow: 0 0 5px #ffd700; +} + +.dh-importer-preview .image-picker img { + display: block; + object-fit: cover; + background: #000; +} + +.dh-importer-preview .attack-pill { + display: inline-flex; + align-items: center; + background: #2a2a2a; + padding: 4px 16px 4px 8px; + /* Adjusted padding for icon */ + border-radius: 4px; + border: 1px solid #444; + color: #ccc; + font-size: 1.1em; + box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.5); + gap: 5px; +} + +.dh-importer-preview .attack-pill .image-picker { + border: none; + box-shadow: none; + background: transparent; +} + +.dh-importer-preview .feature-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + margin-bottom: 5px; } \ No newline at end of file diff --git a/templates/importer.hbs b/templates/importer.hbs index 833799c..4ad3828 100644 --- a/templates/importer.hbs +++ b/templates/importer.hbs @@ -13,7 +13,7 @@