Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2ff4b436 | |||
| f5153485af | |||
| 3e9bc42ca4 | |||
| b6a1af9926 |
5 changed files with 184 additions and 26 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "dh-importer",
|
"id": "dh-importer",
|
||||||
"title": "Daggerheart Statblock Importer",
|
"title": "Daggerheart Statblock Importer",
|
||||||
"version": "1.1.0",
|
"version": "1.2.2",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": "13",
|
"minimum": "13",
|
||||||
"verified": "13"
|
"verified": "13"
|
||||||
|
|
@ -34,5 +34,5 @@
|
||||||
"description": "Imports Adversaries and Environments from text blocks into the Daggerheart system.",
|
"description": "Imports Adversaries and Environments from text blocks into the Daggerheart system.",
|
||||||
"url": "https://github.com/cptn-cosmo/dh-importer",
|
"url": "https://github.com/cptn-cosmo/dh-importer",
|
||||||
"manifest": "https://git.geeks.gay/cosmo/dh-importer/raw/branch/main/module.json",
|
"manifest": "https://git.geeks.gay/cosmo/dh-importer/raw/branch/main/module.json",
|
||||||
"download": "https://git.geeks.gay/cosmo/dh-importer/releases/download/1.1.0/dh-importer.zip"
|
"download": "https://git.geeks.gay/cosmo/dh-importer/releases/download/1.2.2/dh-importer.zip"
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
this.step = "input";
|
this.step = "input";
|
||||||
this.parsedData = null;
|
this.parsedData = null;
|
||||||
this.parsedDataType = null;
|
this.parsedDataType = null;
|
||||||
|
this.inputText = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
static DEFAULT_OPTIONS = {
|
static DEFAULT_OPTIONS = {
|
||||||
|
|
@ -45,7 +46,10 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
types: {
|
types: {
|
||||||
adversary: "Adversary",
|
adversary: "Adversary",
|
||||||
environment: "Environment"
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +79,9 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
const text = form.querySelector("textarea[name='text']").value;
|
const text = form.querySelector("textarea[name='text']").value;
|
||||||
const type = form.querySelector("select[name='type']").value;
|
const type = form.querySelector("select[name='type']").value;
|
||||||
|
|
||||||
|
// Store input text
|
||||||
|
this.inputText = text;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
ui.notifications.warn("Please enter text to import.");
|
ui.notifications.warn("Please enter text to import.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -147,7 +154,10 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.`);
|
ui.notifications.info(`Successfully imported ${this.parsedData.length} documents.`);
|
||||||
this.close();
|
this.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,10 @@ export class DHImporter {
|
||||||
// Ranges: Melee, Very Close, Close, Far, Very Far
|
// Ranges: Melee, Very Close, Close, Far, Very Far
|
||||||
const rangeMatch = rest.match(/^(Melee|Very Close|Close|Far|Very Far)/i);
|
const rangeMatch = rest.match(/^(Melee|Very Close|Close|Far|Very Far)/i);
|
||||||
if (rangeMatch) {
|
if (rangeMatch) {
|
||||||
data.system.attack.range = rangeMatch[1].toLowerCase();
|
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();
|
rest = rest.substring(rangeMatch[0].length).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +263,7 @@ export class DHImporter {
|
||||||
formula = dmgMatch[1];
|
formula = dmgMatch[1];
|
||||||
type = dmgMatch[2].toLowerCase();
|
type = dmgMatch[2].toLowerCase();
|
||||||
if (type === "phy") type = "physical";
|
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({
|
data.system.attack.damage.parts.push({
|
||||||
|
|
@ -276,8 +279,19 @@ export class DHImporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static _parseExperienceLine(line, data) {
|
static _parseExperienceLine(line, data) {
|
||||||
// "Ambusher +3"
|
// Handle "Experience: Manipulate +2, Infiltrate +2" or "Knowledge (Arcana, History) +2"
|
||||||
const match = line.match(/(.+?)\s+([+-]?\d+)$/);
|
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) {
|
if (match) {
|
||||||
const currentCount = Object.keys(data.system.experiences).length;
|
const currentCount = Object.keys(data.system.experiences).length;
|
||||||
const key = "exp" + currentCount;
|
const key = "exp" + currentCount;
|
||||||
|
|
@ -285,8 +299,12 @@ export class DHImporter {
|
||||||
name: match[1].trim(),
|
name: match[1].trim(),
|
||||||
value: parseInt(match[2])
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create feature item data
|
* Helper to create feature item data
|
||||||
|
|
@ -311,10 +329,18 @@ export class DHImporter {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAction) {
|
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 actionId = foundry.utils.randomID();
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
_id: actionId,
|
_id: actionId,
|
||||||
type: "attack",
|
type: actionType,
|
||||||
name: buffer.name,
|
name: buffer.name,
|
||||||
actionType: "action",
|
actionType: "action",
|
||||||
img: "icons/svg/item-bag.svg",
|
img: "icons/svg/item-bag.svg",
|
||||||
|
|
@ -324,7 +350,7 @@ export class DHImporter {
|
||||||
damage: { parts: [], includeBase: false, direct: false },
|
damage: { parts: [], includeBase: false, direct: false },
|
||||||
range: "",
|
range: "",
|
||||||
roll: {
|
roll: {
|
||||||
type: "action", // Default to action, switch to attack if detected
|
type: rollType,
|
||||||
diceRolling: { multiplier: "flat", dice: "d6" }
|
diceRolling: { multiplier: "flat", dice: "d6" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -361,7 +387,11 @@ export class DHImporter {
|
||||||
// Parse Range
|
// Parse Range
|
||||||
const rangeMatch = buffer.description.match(/(Melee|Very Close|Close|Far|Very Far)/i);
|
const rangeMatch = buffer.description.match(/(Melee|Very Close|Close|Far|Very Far)/i);
|
||||||
if (rangeMatch) {
|
if (rangeMatch) {
|
||||||
action.range = rangeMatch[1].toLowerCase();
|
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"
|
// Parse Damage: "deal 3d4+10 direct physical damage"
|
||||||
|
|
@ -376,7 +406,7 @@ export class DHImporter {
|
||||||
|
|
||||||
// Map short codes
|
// Map short codes
|
||||||
if (type === "phy") type = "physical";
|
if (type === "phy") type = "physical";
|
||||||
else if (type === "mag") type = "magic";
|
else if (type === "mag" || type === "magic") type = "magical";
|
||||||
|
|
||||||
action.damage.direct = isDirect;
|
action.damage.direct = isDirect;
|
||||||
action.damage.parts.push({
|
action.damage.parts.push({
|
||||||
|
|
@ -529,19 +559,86 @@ export class DHImporter {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createDocuments(dataList, type) {
|
static async createDocuments(dataList, type, options = {}) {
|
||||||
if (!dataList || dataList.length === 0) return;
|
if (!dataList || dataList.length === 0) return;
|
||||||
|
|
||||||
|
const { target, sortTier } = options;
|
||||||
const Actor = getDocumentClass("Actor");
|
const Actor = getDocumentClass("Actor");
|
||||||
|
const Folder = getDocumentClass("Folder");
|
||||||
const actorsToCreate = [];
|
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) {
|
for (const data of dataList) {
|
||||||
// Process Items based on 'useCompendium' flags or similar set by the UI
|
// Process Items
|
||||||
// The UI should have modified data.items or we loop through and replace based on user choice
|
|
||||||
const finalItems = [];
|
const finalItems = [];
|
||||||
for (const item of data.items) {
|
for (const item of data.items) {
|
||||||
if (data.useFeatures && data.useFeatures[item.name]) {
|
if (data.useFeatures && data.useFeatures[item.name]) {
|
||||||
// User selected to use existing feature
|
|
||||||
const uuid = data.useFeatures[item.name];
|
const uuid = data.useFeatures[item.name];
|
||||||
const original = await fromUuid(uuid);
|
const original = await fromUuid(uuid);
|
||||||
if (original) {
|
if (original) {
|
||||||
|
|
@ -555,21 +652,43 @@ export class DHImporter {
|
||||||
}
|
}
|
||||||
data.items = finalItems;
|
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
|
// Clean up temporary flags
|
||||||
const shouldOpen = data.openSheet;
|
const shouldOpen = data.openSheet;
|
||||||
delete data.foundFeatures;
|
delete data.foundFeatures;
|
||||||
delete data.useFeatures;
|
delete data.useFeatures;
|
||||||
delete data.openSheet;
|
delete data.openSheet;
|
||||||
|
|
||||||
|
// If finding an existing actor to update could be a future feature, but for now we create new.
|
||||||
actorsToCreate.push({ data, shouldOpen });
|
actorsToCreate.push({ data, shouldOpen });
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdActors = await Actor.createDocuments(actorsToCreate.map(a => a.data));
|
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
|
// Open sheets
|
||||||
if (createdActors && createdActors.length > 0) {
|
if (createdActors && createdActors.length > 0) {
|
||||||
for (let i = 0; i < createdActors.length; i++) {
|
for (let i = 0; i < createdActors.length; i++) {
|
||||||
if (actorsToCreate[i].shouldOpen) {
|
// 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);
|
createdActors[i].sheet.render(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<div class="form-group" style="flex: 1;">
|
<div class="form-group" style="flex: 1;">
|
||||||
<label>Paste Statblocks</label>
|
<label>Paste Statblocks</label>
|
||||||
<textarea name="text" placeholder="Paste data here..."></textarea>
|
<textarea name="text" placeholder="Paste data here...">{{inputText}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-footer">
|
<div class="action-footer">
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,35 @@
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="import-options"
|
||||||
|
style="margin-top: 10px; padding: 10px; background: rgba(0,0,0,0.1); border-radius: 5px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Import Target</label>
|
||||||
|
<select name="importTarget">
|
||||||
|
<option value="">World (Root)</option>
|
||||||
|
{{#if folders.length}}
|
||||||
|
<optgroup label="Folders">
|
||||||
|
{{#each folders}}
|
||||||
|
<option value="{{this.id}}">{{this.name}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</optgroup>
|
||||||
|
{{/if}}
|
||||||
|
{{#if packs.length}}
|
||||||
|
<optgroup label="Compendiums">
|
||||||
|
{{#each packs}}
|
||||||
|
<option value="{{this.collection}}">{{this.metadata.label}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</optgroup>
|
||||||
|
{{/if}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="sortTier"> Sort into Sub-folders by Tier
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="action-footer">
|
<div class="action-footer">
|
||||||
<button type="button" data-action="back"><i class="fas fa-arrow-left"></i> Back</button>
|
<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>
|
<button type="button" data-action="import"><i class="fas fa-file-import"></i> Confirm Import</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue