Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2ff4b436 | |||
| f5153485af | |||
| 3e9bc42ca4 | |||
| b6a1af9926 | |||
| fd0e718a1c | |||
| 74f123ecf2 | |||
| bbf4ecbc87 | |||
| b06d89c20e | |||
| 9cacbbbad4 |
6 changed files with 508 additions and 93 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "dh-importer",
|
"id": "dh-importer",
|
||||||
"title": "Daggerheart Importer",
|
"title": "Daggerheart Statblock Importer",
|
||||||
"version": "1.0.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.0.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,15 +46,42 @@ 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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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) {
|
static async onParse(event, target) {
|
||||||
const form = this.element;
|
const form = this.element;
|
||||||
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;
|
||||||
|
|
@ -86,19 +114,50 @@ export class DHImporterApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
|
|
||||||
static async onImport(event, target) {
|
static async onImport(event, target) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormDataExtended(this.element).object;
|
const formData = new foundry.applications.ux.FormDataExtended(this.element).object;
|
||||||
|
|
||||||
for (const actor of this.parsedData) {
|
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 = {};
|
actor.useFeatures = {};
|
||||||
for (const item of actor.items) {
|
for (const item of actor.items) {
|
||||||
const key = `useFeatures.${actor.name}.${item.name}`;
|
const key = `useFeatures.${actor.name}.${item.name}`;
|
||||||
if (formData[key]) {
|
if (formData[key]) {
|
||||||
actor.useFeatures[item.name] = 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.`);
|
ui.notifications.info(`Successfully imported ${this.parsedData.length} documents.`);
|
||||||
this.close();
|
this.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export class DHImporter {
|
||||||
const data = {
|
const data = {
|
||||||
name: "Unknown Adversary",
|
name: "Unknown Adversary",
|
||||||
type: "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: {
|
system: {
|
||||||
tier: 1,
|
tier: 1,
|
||||||
type: "standard",
|
type: "standard",
|
||||||
|
|
@ -81,43 +83,53 @@ export class DHImporter {
|
||||||
data.system.type = match[2].toLowerCase();
|
data.system.type = match[2].toLowerCase();
|
||||||
}
|
}
|
||||||
else if (line.match(/^Difficulty:/i)) {
|
else if (line.match(/^Difficulty:/i)) {
|
||||||
// Can be "Difficulty: 14" or "Difficulty: 11 | Thresholds: ..."
|
// Can be "Difficulty: 14" or "Difficulty: 11 | Thresholds: ..." or "Difficulty: 12Thresholds: 5/9HP: 4Stress: 3"
|
||||||
const valueMatch = line.match(/Difficulty:\s*(\d+)/i);
|
|
||||||
if (valueMatch) data.system.difficulty = parseInt(valueMatch[1]);
|
|
||||||
|
|
||||||
// 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("|")) {
|
if (line.includes("|")) {
|
||||||
const parts = line.split("|").map(p => p.trim());
|
const parts = line.split("|").map(p => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.startsWith("HP:")) {
|
// Fallback for pipes if regex somehow missed or overwrote (unlikely but safe)
|
||||||
data.system.resources.hitPoints.max = parseInt(part.split(":")[1]);
|
// Actually the regex above is better.
|
||||||
} 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)) {
|
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);
|
const bonusMatch = line.match(/(?:Attack|ATK):\s*([+-]?\d+)/i);
|
||||||
if (bonusMatch) data.system.attack.roll.bonus = parseInt(bonusMatch[1]);
|
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 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];
|
const nextLine = lines[lineIndex + 1];
|
||||||
if (nextLine.includes(":") && !nextLine.match(/^(Experience|Features|Motives|Difficulty)/)) {
|
if (nextLine.includes(":") && !nextLine.match(/^(Experience|Features|Motives|Difficulty)/)) {
|
||||||
// Likely the weapon details: "Claws: Melee | 1d8+3 phy"
|
// Likely the weapon details: "Claws: Melee | 1d8+3 phy"
|
||||||
DHImporter._parseAttackDetails(nextLine, data);
|
DHImporter._parseAttackDetails(nextLine, data);
|
||||||
lineIndex++; // Consume next line
|
lineIndex++; // Consume next line
|
||||||
}
|
}
|
||||||
} else if (line.includes("|")) {
|
} else if (restOfLine.length > 0) {
|
||||||
// Inline formatting
|
// Inline formatting (smashed or piped)
|
||||||
const parts = line.split("|").slice(1).join("|"); // remove ATK part
|
if (restOfLine.startsWith("|")) restOfLine = restOfLine.substring(1).trim();
|
||||||
DHImporter._parseAttackDetails(parts, data);
|
DHImporter._parseAttackDetails(restOfLine, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (line.match(/^Experience:/i)) {
|
else if (line.match(/^Experience:/i)) {
|
||||||
|
|
@ -165,18 +177,12 @@ export class DHImporter {
|
||||||
} else if (prevLine.match(/STRESS:/i)) {
|
} else if (prevLine.match(/STRESS:/i)) {
|
||||||
data.system.resources.stress.max = val;
|
data.system.resources.stress.max = val;
|
||||||
} else if (prevLine.includes("minor") || prevPrevLine.includes("minor")) {
|
} else if (prevLine.includes("minor") || prevPrevLine.includes("minor")) {
|
||||||
data.system.damageThresholds.minor = val; // System doesn't have minor usually?
|
data.system.damageThresholds.minor = val;
|
||||||
// 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")) {
|
} else if (prevLine.includes("major") || prevPrevLine.includes("major")) {
|
||||||
data.system.damageThresholds.major = val;
|
data.system.damageThresholds.major = val;
|
||||||
} else if (prevLine.includes("severe") || prevPrevLine.includes("severe")) {
|
} else if (prevLine.includes("severe") || prevPrevLine.includes("severe")) {
|
||||||
data.system.damageThresholds.severe = val;
|
data.system.damageThresholds.severe = val;
|
||||||
} else if (prevLine.includes("HP")) {
|
} 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("minor")) { /* ignore or store */ }
|
||||||
if (prevPrevLine.includes("major")) data.system.damageThresholds.major = val;
|
if (prevPrevLine.includes("major")) data.system.damageThresholds.major = val;
|
||||||
if (prevPrevLine.includes("severe")) data.system.damageThresholds.severe = val;
|
if (prevPrevLine.includes("severe")) data.system.damageThresholds.severe = val;
|
||||||
|
|
@ -186,8 +192,9 @@ export class DHImporter {
|
||||||
|
|
||||||
// FEATURES
|
// FEATURES
|
||||||
else if (currentSection === "features") {
|
else if (currentSection === "features") {
|
||||||
const featureMatch = line.match(/^(.+?)\s+[–-]\s+(\w+)(?::\s*(.*))?$/);
|
|
||||||
// "Name - Type" (Description on next line) OR "Name - Type: Description"
|
// "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 (featureMatch) {
|
||||||
if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer));
|
if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer));
|
||||||
|
|
@ -216,61 +223,75 @@ export class DHImporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static _parseAttackDetails(line, data) {
|
static _parseAttackDetails(line, data) {
|
||||||
// Claws: Melee | 1d8+3 phy OR Claws: Melee 1d8+3 phy
|
// Claws: Melee | 1d8+3 phy OR Claws: Melee 1d8+3 phy OR Claws: Melee1d6+2 phy
|
||||||
// Simple heuristic splitting
|
|
||||||
let parts = line.split("|").map(p => p.trim());
|
// 1. Extract Name
|
||||||
if (parts.length === 1) {
|
let name = "Attack";
|
||||||
// Try splitting by : for name/range
|
let rest = line;
|
||||||
const firstColon = line.indexOf(":");
|
|
||||||
if (firstColon > -1) {
|
if (line.includes(":")) {
|
||||||
const name = line.substring(0, firstColon).trim();
|
const parts = line.split(":");
|
||||||
const rest = line.substring(firstColon + 1).trim();
|
name = parts[0].trim();
|
||||||
// "Melee 1d8+3 phy" -> split by first space? or known ranges?
|
rest = parts.slice(1).join(":").trim();
|
||||||
// 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
|
data.system.attack.name = name;
|
||||||
// Part 0: Name: Range
|
|
||||||
if (parts.length > 0) {
|
// 2. Extract Range
|
||||||
const nameRange = parts[0].split(":");
|
// Ranges: Melee, Very Close, Close, Far, Very Far
|
||||||
data.system.attack.name = nameRange[0].trim();
|
const rangeMatch = rest.match(/^(Melee|Very Close|Close|Far|Very Far)/i);
|
||||||
if (nameRange[1]) data.system.attack.range = nameRange[1].trim().toLowerCase();
|
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) {
|
// 3. Extract Damage
|
||||||
const dmgStr = parts[1];
|
// Formula + Type
|
||||||
const dmgMatch = dmgStr.match(/^(.+?)\s+(\w+)$/);
|
if (rest) {
|
||||||
let formula = dmgStr;
|
// Remove optional pipes
|
||||||
|
rest = rest.replace(/^\|\s*/, "");
|
||||||
|
|
||||||
|
// "1d6+2 phy"
|
||||||
|
const dmgMatch = rest.match(/^(.+?)\s+(\w+)$/);
|
||||||
|
let formula = rest;
|
||||||
let type = "physical";
|
let type = "physical";
|
||||||
|
|
||||||
if (dmgMatch) {
|
if (dmgMatch) {
|
||||||
formula = dmgMatch[1];
|
formula = dmgMatch[1];
|
||||||
type = dmgMatch[2].toLowerCase();
|
type = dmgMatch[2].toLowerCase();
|
||||||
|
|
||||||
// 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
data.system.attack.damage.parts.push({
|
data.system.attack.damage.parts.push({
|
||||||
type: [type],
|
type: [type],
|
||||||
applyTo: "hitPoints",
|
applyTo: "hitPoints",
|
||||||
value: {
|
value: {
|
||||||
custom: { enabled: true, formula: formula },
|
custom: { enabled: true, formula: formula },
|
||||||
multiplier: "flat", dice: "d6"
|
multiplier: "flat", dice: "d6",
|
||||||
|
flatMultiplier: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -278,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
|
||||||
|
|
@ -290,13 +315,115 @@ export class DHImporter {
|
||||||
// sometimes with specific actions embedded.
|
// sometimes with specific actions embedded.
|
||||||
// We will put the type in the description.
|
// We will put the type in the description.
|
||||||
|
|
||||||
return {
|
const isAction = buffer.typeHint?.toLowerCase() === "action";
|
||||||
|
|
||||||
|
const item = {
|
||||||
name: buffer.name,
|
name: buffer.name,
|
||||||
type: "feature",
|
type: "feature",
|
||||||
|
img: "icons/svg/item-bag.svg",
|
||||||
system: {
|
system: {
|
||||||
description: `<p><strong>${buffer.typeHint}</strong>: ${buffer.description}</p>`
|
description: `<p><strong>${buffer.typeHint}</strong>: ${buffer.description}</p>`,
|
||||||
|
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 = {
|
const data = {
|
||||||
name: lines[0],
|
name: lines[0],
|
||||||
type: "environment",
|
type: "environment",
|
||||||
|
img: "systems/daggerheart/assets/icons/documents/actors/forest.svg",
|
||||||
system: {
|
system: {
|
||||||
tier: 1,
|
tier: 1,
|
||||||
type: "exploration",
|
type: "exploration",
|
||||||
|
|
@ -365,7 +493,7 @@ export class DHImporter {
|
||||||
currentSection = "features";
|
currentSection = "features";
|
||||||
} else if (currentSection === "features") {
|
} else if (currentSection === "features") {
|
||||||
// Parsing Features: "Name - Type: Description"
|
// Parsing Features: "Name - Type: Description"
|
||||||
const featureMatch = line.match(/^(.+?)\s+[–-]\s+(\w+):\s*(.*)/);
|
const featureMatch = line.match(/^(.+?)\s*[–—-]\s*(\w+):\s*(.*)/);
|
||||||
if (featureMatch) {
|
if (featureMatch) {
|
||||||
if (featureBuffer) {
|
if (featureBuffer) {
|
||||||
data.items.push(DHImporter._createFeatureItem(featureBuffer));
|
data.items.push(DHImporter._createFeatureItem(featureBuffer));
|
||||||
|
|
@ -431,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) {
|
||||||
|
|
@ -457,13 +652,46 @@ 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;
|
||||||
delete data.foundFeatures;
|
delete data.foundFeatures;
|
||||||
delete data.useFeatures;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dh-importer-app button {
|
.dh-importer-app .window-content button {
|
||||||
background: linear-gradient(180deg, #5b1c1c 0%, #3a0e0e 100%);
|
background: linear-gradient(180deg, #5b1c1c 0%, #3a0e0e 100%);
|
||||||
border: 1px solid #7a6a4a;
|
border: 1px solid #7a6a4a;
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
|
|
@ -90,14 +90,14 @@
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dh-importer-app button:hover {
|
.dh-importer-app .window-content button:hover {
|
||||||
background: linear-gradient(180deg, #7a2828 0%, #561414 100%);
|
background: linear-gradient(180deg, #7a2828 0%, #561414 100%);
|
||||||
box-shadow: 0 0 8px #cbb484;
|
box-shadow: 0 0 8px #cbb484;
|
||||||
text-shadow: 0 0 5px #ffd700;
|
text-shadow: 0 0 5px #ffd700;
|
||||||
border-color: #ffd700;
|
border-color: #ffd700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dh-importer-app button i {
|
.dh-importer-app .window-content button i {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,3 +255,70 @@
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid #4a4a4a;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,34 @@
|
||||||
<div class="actor-list">
|
<div class="actor-list">
|
||||||
{{#each parsedData as |actor idx|}}
|
{{#each parsedData as |actor idx|}}
|
||||||
<div class="actor-preview">
|
<div class="actor-preview">
|
||||||
|
<div class="header-row">
|
||||||
|
<div class="image-picker">
|
||||||
|
<img src="{{actor.img}}" data-edit="img.{{actor.name}}" height="48" width="48" />
|
||||||
|
<input type="text" name="img.{{actor.name}}" value="{{actor.img}}" style="display:none">
|
||||||
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<span>{{actor.name}}</span>
|
<span>{{actor.name}}</span>
|
||||||
<small>Tier {{actor.system.tier}} {{actor.system.type}}</small>
|
<small>Tier {{actor.system.tier}} {{actor.system.type}}</small>
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
{{#if actor.system.resources.hitPoints.max}}<span><strong>HP:</strong>
|
{{#if actor.system.resources.hitPoints.max}}<span><strong>HP:</strong>
|
||||||
{{actor.system.resources.hitPoints.max}}</span>{{/if}}
|
{{actor.system.resources.hitPoints.max}}</span>{{/if}}
|
||||||
{{#if actor.system.resources.stress.max}}<span><strong>Stress:</strong>
|
{{#if actor.system.resources.stress.max}}<span><strong>Stress:</strong>
|
||||||
{{actor.system.resources.stress.max}}</span>{{/if}}
|
{{actor.system.resources.stress.max}}</span>{{/if}}
|
||||||
<span><strong>Difficulty:</strong> {{actor.system.difficulty}}</span>
|
<span><strong>Difficulty:</strong> {{actor.system.difficulty}}</span>
|
||||||
|
{{#if actor.system.attack.name}}
|
||||||
|
{{#if actor.system.attack.name}}
|
||||||
|
<span class="attack-pill">
|
||||||
|
<div class="image-picker small" style="border:none; margin-right:5px; width:24px; height:24px;">
|
||||||
|
<img src="icons/svg/item-bag.svg" data-edit="attackImg.{{actor.name}}" height="24" width="24" />
|
||||||
|
<input type="text" name="attackImg.{{actor.name}}" value="icons/svg/item-bag.svg"
|
||||||
|
style="display:none">
|
||||||
|
</div>
|
||||||
|
<strong>Attack:</strong> {{actor.system.attack.name}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="features-list">
|
<div class="features-list">
|
||||||
|
|
@ -20,7 +38,15 @@
|
||||||
{{#each actor.items as |item itemIdx|}}
|
{{#each actor.items as |item itemIdx|}}
|
||||||
{{#if (eq item.type "feature")}}
|
{{#if (eq item.type "feature")}}
|
||||||
<li class="feature-item">
|
<li class="feature-item">
|
||||||
|
<div class="feature-header">
|
||||||
|
<div class="image-picker small">
|
||||||
|
<img src="{{item.img}}" data-edit="itemImg.{{actor.name}}.{{item.name}}" height="24"
|
||||||
|
width="24" />
|
||||||
|
<input type="text" name="itemImg.{{actor.name}}.{{item.name}}" value="{{item.img}}"
|
||||||
|
style="display:none">
|
||||||
|
</div>
|
||||||
<strong>{{item.name}}</strong>
|
<strong>{{item.name}}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{#if (lookup ../foundFeatures item.name)}}
|
{{#if (lookup ../foundFeatures item.name)}}
|
||||||
<div class="feature-match">
|
<div class="feature-match">
|
||||||
|
|
@ -40,10 +66,45 @@
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="actor-options">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="openSheet.{{actor.name}}"> Open Sheet after Import
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/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