Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save crueber/7e6d961fa46ab90e2a4336746228c578 to your computer and use it in GitHub Desktop.
Save crueber/7e6d961fa46ab90e2a4336746228c578 to your computer and use it in GitHub Desktop.
D&D 5th Edition Scripts for Tampermonkey
// ==UserScript==
// @name 5e.tools -> statblock YAML exporter (2014 site) w/spellcasting
// @namespace crueber.roll20.statblock.export
// @version 0.3.0
// @description Adds an Export button on 2014.5e.tools bestiary pages to copy YAML for Obsidian statblock, including spellcasting and structured spells
// @match https://2014.5e.tools/*
// @match https://5e.tools/*
// @match https://www.5e.tools/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
// ----------------- helpers -----------------
const q = (r, s) => (r || document).querySelector(s);
const qa = (r, s) => Array.from((r || document).querySelectorAll(s));
const clean = (s) => (s || "").replace(/\s+/g, " ").trim();
const raf = () => new Promise((r) => requestAnimationFrame(r));
const byText = (el) => (el ? el.textContent.trim() : "");
const P = (s) => (s ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s);
function extractNumberFrom(str, re, def = null) {
if (!str) return def;
const m = str.match(re);
return m ? m[1] : def;
}
function parseAbilityTable(root) {
// 6 ability cells in order
const vals = qa(root, "td.ve-text-center");
if (vals.length < 6) return [];
function toScore(valCell) {
const txt = byText(valCell);
const m = txt.match(/(\d+)\s*\([^)]+\)/) || txt.match(/(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
return vals.slice(0, 6).map(toScore);
}
function normalizeSizeTypeAlign(lineEl) {
// Example: "Gargantuan Elemental, Typically Neutral"
const txt = byText(lineEl);
let size = "", type = "", subtype = "", alignment = "";
if (!txt) return { size, type, subtype, alignment };
const parts = txt.split(",");
const first = parts[0] || "";
alignment = clean(parts.slice(1).join(","));
const firstParts = first.split(/\s+/);
size = (firstParts[0] || "").toLowerCase();
type = clean(firstParts.slice(1).join(" ")).toLowerCase();
const m = type.match(/^([^(]+)\s*\(([^)]+)\)\s*$/);
if (m) {
type = clean(m[1]);
subtype = clean(m[2]);
}
return { size, type, subtype, alignment: alignment ? alignment.toLowerCase() : "" };
}
function parseACHPHD(root) {
const acDiv = qa(root, ".stats__wrp-avoid-token").find((d) =>
/Armor Class/i.test(d.textContent)
);
const hpDiv = qa(root, ".stats__wrp-avoid-token").find((d) =>
/Hit Points/i.test(d.textContent)
);
const ac = acDiv ? extractNumberFrom(acDiv.textContent, /Armor Class\s+(\d+)/i, "") : "";
let hp = "", hit_dice = "";
if (hpDiv) {
const hpNum = hpDiv.querySelector("span.help-subtle") || hpDiv;
hp = extractNumberFrom(hpNum.textContent, /(\d+)/, "");
const hdSpan = hpDiv.querySelector(".roller.render-roller");
if (hdSpan) {
const packed = hdSpan.getAttribute("data-packed-dice");
if (packed) {
try {
hit_dice = JSON.parse(packed).displayText || "";
} catch {}
}
if (!hit_dice) hit_dice = clean(hdSpan.textContent);
}
if (!hit_dice) {
const m = hpDiv.textContent.match(/\(([^)]+)\)/);
if (m) hit_dice = clean(m[1]);
}
}
return { ac: ac ? parseInt(ac, 10) : "", hp: hp ? parseInt(hp, 10) : "", hit_dice };
}
function parseSpeed(root) {
const div = qa(root, "div").find((d) => /^Speed/i.test(d.textContent.trim()));
if (!div) return "";
return clean(div.textContent.replace(/^Speed\s*/i, ""));
}
function parseSaves(line) {
// "Saving Throws Wis +12, Cha +11"
const out = {};
const items = line.split(/,\s*/);
items.forEach((it) => {
const m = it.match(/\b(Str|Dex|Con|Int|Wis|Cha)\b.*?([+−-]\d+)/i);
if (m) {
const keyMap = {
str: "strength",
dex: "dexterity",
con: "constitution",
int: "intelligence",
wis: "wisdom",
cha: "charisma",
};
const k = keyMap[m[1].toLowerCase()];
const val = parseInt(m[2].replace("−", "-"), 10);
if (k != null) out[k] = val;
}
});
return out;
}
function parseSkills(line) {
// "Skills Perception +17, Insight +10"
const out = {};
const items = line.split(/,\s*/);
items.forEach((it) => {
const m = it.match(
/\b(Acrobatics|Animal Handling|Arcana|Athletics|Deception|History|Insight|Intimidation|Investigation|Medicine|Nature|Perception|Performance|Persuasion|Religion|Sleight of Hand|Stealth|Survival)\b.*?([+−-]\d+)/i
);
if (m) {
const key = m[1].toLowerCase().replace(/\s+/g, "_");
const val = parseInt(m[2].replace("−", "-"), 10);
out[key] = val;
}
});
return out;
}
function parseListLine(label, root) {
const div = qa(root, "div, td").find((d) =>
new RegExp("^" + label + "\\b", "i").test(d.textContent.trim())
);
if (!div) return "";
return clean(div.textContent.replace(new RegExp("^" + label + "\\s*", "i"), ""));
}
function parseCRPB() {
const tr = q(document, "tr.relative");
let cr = "", xp = "", pb = "";
if (tr) {
const tds = qa(tr, "td");
const left = tds[0] ? tds[0].textContent : "";
const right = tds[1] ? tds[1].textContent : "";
const m = left.match(/Challenge\s+([^(]+)\s*\(([^)]+)\)/i);
if (m) {
cr = clean(m[1]);
xp = clean(m[2]).replace(/XP/i, "").trim();
}
const mpb = right.match(/Proficiency Bonus\s*([+−-]?\d+)/i);
if (mpb) pb = parseInt(mpb[1].replace("−", "-"), 10);
}
return { cr, xp, pb };
}
function convertActionBlock(name, blk) {
const raw = clean(blk.textContent);
const desc = clean(raw.replace(new RegExp("^" + P(name) + "\\.\\s*", "i"), ""));
let attack_bonus = 0;
const toHitM = desc.match(/\+(\d+)\s+to hit/i);
if (toHitM) attack_bonus = parseInt(toHitM[1], 10);
// grab all "(XdY [+ N])" parts in order
const diceParts = [];
const dmgDiceRe = /\((\d+d\d+(?:\s*[+−-]\s*\d+)?)\)/gi;
let m;
while ((m = dmgDiceRe.exec(desc))) {
diceParts.push(m[1].replace(/\s*−\s*/g, " - ").replace(/\s*\+\s*/g, " + "));
}
let damage_dice = "";
let damage_bonus = undefined;
if (diceParts.length >= 1) {
const first = diceParts[0];
damage_dice = diceParts.join(" + ");
const mb = first.match(/^(\d+d\d+)\s*([+−-]\s*\d+)$/);
if (mb) {
damage_dice = [mb[1]].concat(diceParts.slice(1)).join(" + ");
damage_bonus = parseInt(mb[2].replace("−", "-").replace(/\s+/g, ""), 10);
}
}
return {
name,
desc,
attack_bonus: isNaN(attack_bonus) ? 0 : attack_bonus,
...(damage_dice ? { damage_dice } : {}),
...(typeof damage_bonus === "number" ? { damage_bonus } : {}),
};
}
function extractSpellLines(text) {
// Pull structured lines if present
const lines = text.split(/\n+/).map((s) => s.trim()).filter(Boolean);
const results = [];
const headerRe =
/(is|are)\s+a\s+\d+(?:st|nd|rd|th)-level\s+spellcaster\b.*?(?:prepared:|known:|prepared\.|known\.)/i;
const cantripsRe = /^(?:cantrips|cantrip)s?\s*\((?:at\s+will|at-will)\)\s*:/i;
const atWillRe = /^(?:at\s+will|at-will)\s*:/i;
const levelSlotsRe = /^\d+(?:st|nd|rd|th)\s+level\s*\([^)]*\)\s*:/i;
const perDayRe = /^\d+\/day(?:\s+each)?\s*:/i;
for (const ln of lines) {
if (
headerRe.test(ln) ||
cantripsRe.test(ln) ||
atWillRe.test(ln) ||
levelSlotsRe.test(ln) ||
perDayRe.test(ln)
) {
results.push(ln.replace(/\s+/g, " "));
}
}
return results;
}
function parseTraitsActionsAndSpellcasting(root) {
const traits = [];
const actions = [];
const legendaries = [];
let spellcastingText = "";
let spellsArray = [];
const allBlocks = qa(root, '.stats__sect-row-inner > div.rd__b');
const allNodes = qa(root, "*");
const actionsHeaderNode = allNodes.find(
(el) => el.matches("h3.stats__sect-header-inner") && /Actions/i.test(el.textContent)
);
const legendaryHeaderNode = allNodes.find(
(el) => el.matches("h3.stats__sect-header-inner") && /Legendary Actions/i.test(el.textContent)
);
const isBeforeNode = (el, node) =>
node ? (el.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) : true;
allBlocks.forEach((blk) => {
const titleEl = q(blk, ".entry-title-inner");
const rawName = titleEl ? clean(titleEl.textContent.replace(/\.$/, "")) : "";
const rawText = blk.textContent.trim();
const bodyText = clean(rawText.replace(new RegExp("^" + P(rawName) + "\\.\\s*", "i"), ""));
const isSpellHeader = /^innate spellcasting\b|^spellcasting\b/i.test(rawName);
if (!actionsHeaderNode || isBeforeNode(blk, actionsHeaderNode)) {
// Traits
traits.push({ name: rawName, desc: bodyText, attack_bonus: 0 });
if (isSpellHeader) {
// Preserve paragraphs (keeps list formatting best)
const paraLines = [];
qa(blk, "p").forEach((p) => {
const t = p.textContent.replace(/\s+\n/g, "\n").trim();
if (t) paraLines.push(t);
});
if (!paraLines.length) paraLines.push(bodyText);
// Surface DC / spell attack in header
const firstLine = paraLines[0] || "";
const dcM = firstLine.match(/(?:save\s*DC|DC)\s*(\d+)/i);
const atkM = firstLine.match(/(?:spell attack[s]?\s*)\+?(-?\d+)/i);
let header = rawName;
if (dcM) header += ` (DC ${dcM[1]})`;
if (atkM) header += ` (Spell Attack ${atkM[1].startsWith("+") ? atkM[1] : "+" + atkM[1]})`;
spellcastingText = [header + ".", ...paraLines].join("\n");
// Extract structured spell lines (header, cantrips, slots, per-day)
const joined = paraLines.join("\n");
const spellLines = extractSpellLines(joined);
if (spellLines.length) spellsArray = spellLines;
}
} else if (!legendaryHeaderNode || isBeforeNode(blk, legendaryHeaderNode)) {
// Actions
actions.push(convertActionBlock(rawName, blk));
} else {
// Legendary blocks sometimes appear here; list items handled below
}
});
// Legendary actions list items
const legList = qa(root, ".rd__list.rd__list-hang-notitle > li .rd__p-list-item");
legList.forEach((li) => {
const nameEl = q(li, ".bold.rd__list-item-name");
const name = nameEl ? clean(nameEl.textContent.replace(/\.$/, "")) : "";
const desc = clean(li.textContent.replace(nameEl ? nameEl.textContent : "", ""));
legendaries.push({ name, desc, attack_bonus: 0 });
});
return { traits, actions, legendaries, spellcastingText, spellsArray };
}
function parseLanguages(root) {
const div = qa(root, "div, td").find((d) => /^Languages\b/i.test(d.textContent.trim()));
if (!div) return "—";
let txt = clean(div.textContent.replace(/^Languages\s*/i, ""));
if (!txt || txt === "—" || txt === "None") return "—";
return txt;
}
function parseSensesLine(root) {
const div = qa(root, "div, td").find((d) => /^Senses\b/i.test(d.textContent.trim()));
if (!div) return "";
return clean(div.textContent.replace(/^Senses\s*/i, ""));
}
function buildYAML(sb) {
function esc(s) {
if (s == null) return "";
const needs = /[:#,-]|^\s|^$|\s$|["'\n]/.test(String(s));
return needs ? JSON.stringify(String(s)) : String(s);
}
function emitList(name, arr) {
if (!arr || !arr.length) return "";
let out = `${name}:\n`;
arr.forEach((it) => {
out += ` - name: ${esc(it.name || "")}\n`;
if (it.desc) out += ` desc: |-\n ${it.desc.replace(/\n/g, "\n ")}\n`;
if (it.attack_bonus != null) out += ` attack_bonus: ${it.attack_bonus}\n`;
if (it.damage_dice) out += ` damage_dice: ${esc(it.damage_dice)}\n`;
if (typeof it.damage_bonus === "number") out += ` damage_bonus: ${it.damage_bonus}\n`;
});
return out;
}
const lines = [];
lines.push("```statblock");
lines.push(`name: ${esc(sb.name || "")}`);
if (sb.source) lines.push(`source: ${esc(sb.source)}`);
if (sb.size) lines.push(`size: ${esc(sb.size)}`);
if (sb.type) lines.push(`type: ${esc(sb.type)}`);
if (sb.subtype) lines.push(`subtype: ${esc(sb.subtype)}`);
if (sb.alignment) lines.push(`alignment: ${esc(sb.alignment)}`);
if (sb.ac != null && sb.ac !== "") lines.push(`ac: ${sb.ac}`);
if (sb.hp != null && sb.hp !== "") lines.push(`hp: ${sb.hp}`);
if (sb.hit_dice) lines.push(`hit_dice: ${esc(sb.hit_dice)}`);
if (sb.speed) lines.push(`speed: ${esc(sb.speed)}`);
if (sb.stats && sb.stats.length === 6) {
lines.push("stats:");
sb.stats.forEach((n) => lines.push(` - ${n}`));
}
if (sb.saves && Object.keys(sb.saves).length) {
lines.push("saves:");
Object.entries(sb.saves).forEach(([k, v]) => lines.push(` - ${k}: ${v}`));
}
if (sb.skillsaves && Object.keys(sb.skillsaves).length) {
lines.push("skillsaves:");
Object.entries(sb.skillsaves).forEach(([k, v]) => lines.push(` - ${k}: ${v}`));
}
lines.push(`damage_vulnerabilities: ${esc(sb.damage_vulnerabilities || "")}`);
lines.push(`damage_resistances: ${esc(sb.damage_resistances || "")}`);
lines.push(`damage_immunities: ${esc(sb.damage_immunities || "")}`);
lines.push(`condition_immunities: ${esc(sb.condition_immunities || "")}`);
if (sb.senses) lines.push(`senses: ${esc(sb.senses)}`);
if (sb.languages) lines.push(`languages: ${esc(sb.languages)}`);
if (sb.cr) lines.push(`cr: ${esc(sb.cr)}`);
lines.push("bestiary: true");
if (sb.pb != null && sb.pb !== "") lines.push(`pb: ${sb.pb}`);
// Spellcasting prose
if (sb.spellcasting && sb.spellcasting.trim()) {
lines.push("spellcasting: |-");
sb.spellcasting.split("\n").forEach((ln) => lines.push(` ${ln}`));
}
// Structured spell lines
if (sb.spells && sb.spells.length) {
lines.push("spells:");
sb.spells.forEach((ln) => lines.push(` - ${esc(ln)}`));
}
if (sb.traits?.length) lines.push(emitList("traits", sb.traits).trimEnd());
if (sb.actions?.length) lines.push(emitList("actions", sb.actions).trimEnd());
if (sb.legendary_actions?.length)
lines.push(emitList("legendary_actions", sb.legendary_actions).trimEnd());
lines.push("```");
return lines.join("\n");
}
function extractMonsterToYAML() {
const table = q(document, "table#pagecontent.stats");
if (!table) throw new Error("Monster stat block not found.");
const nameH = q(table, ".stats__h-name");
const name = nameH ? clean(nameH.textContent) : "";
const srcEl = q(table, ".stats__wrp-h-source a[class*=source__]");
const source = srcEl ? clean(srcEl.textContent) : "";
const sizeTypeAlignEl = qa(table, ".stats__wrp-avoid-token i")[0] || qa(table, "td div i")[0];
const { size, type, subtype, alignment } = normalizeSizeTypeAlign(sizeTypeAlignEl);
const { ac, hp, hit_dice } = parseACHPHD(table);
const speed = parseSpeed(table);
const stats = parseAbilityTable(table) || [];
const savesLineDiv = qa(table, "div, td").find((d) =>
/^Saving Throws\b/i.test(d.textContent.trim())
);
const saves = savesLineDiv
? parseSaves(clean(savesLineDiv.textContent.replace(/^Saving Throws\s*/i, "")))
: {};
const skillsLineDiv = qa(table, "div, td").find((d) =>
/^Skills\b/i.test(d.textContent.trim())
);
const skillsaves = skillsLineDiv
? parseSkills(clean(skillsLineDiv.textContent.replace(/^Skills\s*/i, "")))
: {};
const damage_resistances = parseListLine("Damage Resistances", table);
const damage_immunities = parseListLine("Damage Immunities", table);
const damage_vulnerabilities = parseListLine("Damage Vulnerabilities", table);
const condition_immunities = parseListLine("Condition Immunities", table);
const senses = parseSensesLine(table);
const languages = parseLanguages(table);
const { cr, xp, pb } = parseCRPB();
const { traits, actions, legendaries, spellcastingText, spellsArray } =
parseTraitsActionsAndSpellcasting(table);
const sb = {
name,
source,
size,
type,
subtype,
alignment,
ac,
hp,
hit_dice,
speed,
stats,
saves: Object.keys(saves).length
? Object.keys(saves).map((k) => ({ [k]: saves[k] }))
: [],
skillsaves: Object.keys(skillsaves).length
? Object.keys(skillsaves).map((k) => ({ [k]: skillsaves[k] }))
: [],
damage_vulnerabilities,
damage_resistances,
damage_immunities,
condition_immunities,
senses,
languages,
cr,
xp, // retained; Obsidian statblock ignores but it's harmless
pb,
traits,
actions,
legendary_actions: legendaries,
bestiary: true,
spellcasting: spellcastingText || "",
spells: spellsArray || [],
};
return buildYAML(sb);
}
// ----------------- UI injection -----------------
function addButton() {
if (document.getElementById("export-statblock-yaml-btn")) return;
const header =
q(document, ".stats__th-name .split-v-end") ||
q(document, ".stats__th-name") ||
q(document, ".stats__h-name")?.parentElement;
if (!header) return;
const btn = document.createElement("button");
btn.id = "export-statblock-yaml-btn";
btn.className = "ve-btn ve-btn-xs ve-btn-default ml-2";
btn.textContent = "Export statblock YAML";
const modal = document.createElement("div");
Object.assign(modal.style, {
position: "fixed",
top: "10%",
left: "50%",
transform: "translateX(-50%)",
width: "min(900px, 90vw)",
maxHeight: "80vh",
padding: "10px",
background: "#1e1e1e",
border: "1px solid #555",
boxShadow: "0 6px 20px rgba(0,0,0,0.5)",
zIndex: "99999",
display: "none",
});
const ta = document.createElement("textarea");
Object.assign(ta.style, {
width: "100%",
height: "60vh",
color: "#ddd",
background: "#111",
fontFamily: "monospace",
fontSize: "12px",
whiteSpace: "pre",
});
ta.wrap = "off";
const bar = document.createElement("div");
Object.assign(bar.style, {
display: "flex",
justifyContent: "space-between",
marginTop: "6px",
});
const copyBtn = document.createElement("button");
copyBtn.className = "ve-btn ve-btn-default";
copyBtn.textContent = "Copy";
const closeBtn = document.createElement("button");
closeBtn.className = "ve-btn ve-btn-danger";
closeBtn.textContent = "Close";
copyBtn.onclick = async () => {
ta.select();
try {
await navigator.clipboard.writeText(ta.value);
} catch {
document.execCommand("copy");
}
};
closeBtn.onclick = () => (modal.style.display = "none");
bar.appendChild(copyBtn);
bar.appendChild(closeBtn);
modal.appendChild(ta);
modal.appendChild(bar);
btn.addEventListener("click", () => {
try {
const yaml = extractMonsterToYAML();
ta.value = yaml;
modal.style.display = "block";
ta.focus();
ta.select();
} catch (e) {
console.error(e);
alert("Export failed: " + e.message);
}
});
header.appendChild(btn);
document.body.appendChild(modal);
}
const obs = new MutationObserver(() => {
try {
addButton();
} catch {}
});
obs.observe(document.documentElement, { subtree: true, childList: true });
addButton();
})();
// ==UserScript==
// @name Import Roll20 5e NPC from Obsidian statblock
// @namespace crueber.roll20.npc.statblock
// @version 1.3.1
// @description Paste YAML statblock to populate Roll20 5e NPC sheets.
// @match https://app.roll20.net/editor/*
// @match https://app.roll20dev.net/editor/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
// ---------- Small, safe YAML loader ----------
// Tries window.jsyaml.load if present.
// Else uses a compact YAML parser that supports the statblock subset:
// - key: value
// - arrays with "- item" and "- key: value" items
// - nested indentation by 2 spaces
// - literal blocks with | or |- (treated as joined lines)
function loadYAML(text) {
// Prefer Roll20’s/global js-yaml if available
try {
if (window.jsyaml && typeof window.jsyaml.load === "function") {
return window.jsyaml.load(text);
}
} catch (e) {
// ignore and fall back
}
// Fallback quick JSON try
try {
if (text.trim().startsWith("{") || text.trim().startsWith("[")) {
return JSON.parse(text);
}
} catch (e) {
// continue to minimal YAML
}
return parseSimpleYAML(text);
}
function parseSimpleYAML(src) {
const lines = src.replace(/\t/g, " ").split(/\r?\n/);
let i = 0;
function err(msg) {
const ln = Math.min(i + 1, lines.length);
const near = lines[ln - 1] || "";
throw new Error(`${msg} at line ${ln}: ${near}`);
}
function isBlankOrComment(s) {
const t = s.trim();
return t === "" || t.startsWith("#");
}
function coerceScalar(s) {
const t = s.trim();
if (t === "null" || t === "~") return null;
if (t === "true") return true;
if (t === "false") return false;
if (/^-?\d+$/.test(t)) return parseInt(t, 10);
if (/^-?\d+\.\d+$/.test(t)) return parseFloat(t);
const q = t.match(/^"(.*)"$/) || t.match(/^'(.*)'$/);
if (q) return q[1];
return t;
}
function readBlock(expectedIndent) {
// decides if we build an object or array based on first significant line
let mode = null; // "obj" | "arr"
const obj = {};
const arr = [];
function readLiteralBlock(indent) {
const result = [];
while (i < lines.length) {
const m = lines[i].match(/^(\s*)(.*)$/);
const curIndent = m ? m[1].length : 0;
const content = m ? m[2] : lines[i];
if (curIndent < indent) break;
result.push(content);
i++;
}
// join with newlines preserving internal formatting
return result.join("\n");
}
while (i < lines.length) {
let raw = lines[i];
if (isBlankOrComment(raw)) {
i++;
continue;
}
const m = raw.match(/^(\s*)(.*)$/);
const curIndent = m ? m[1].length : 0;
let line = m ? m[2] : raw;
if (curIndent < expectedIndent) break;
if (curIndent > expectedIndent) {
// nested under previous key/list item
break;
}
// list item
if (line.trim().startsWith("- ")) {
if (!mode) mode = "arr";
if (mode !== "arr") err("Mixed mapping and sequence");
line = line.trim().slice(2);
if (line === "" || line === "|" || line === "|-") {
// list item with nested block
i++;
if (line === "|" || line === "|-") {
const lit = readLiteralBlock(expectedIndent + 2);
arr.push(lit);
} else {
const nested = readBlock(expectedIndent + 2);
arr.push(nested);
}
} else {
// could be scalar or inline key: value to start object for this item
if (/^[^:]+:\s*/.test(line)) {
const kv = line.match(/^([^:]+):\s*(.*)$/);
const itemObj = {};
const k = kv[1].trim();
const v = kv[2];
if (v === "" || v === "|" || v === "|-") {
i++;
const val =
v === "|" || v === "|-" ? readLiteralBlock(expectedIndent + 2) : readBlock(expectedIndent + 2);
itemObj[k] = val;
} else {
itemObj[k] = coerceScalar(v);
i++;
}
// also parse any additional nested keys for this same list item
const nested = readBlock(expectedIndent + 2);
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
Object.keys(nested).forEach((kk) => {
itemObj[kk] = nested[kk];
});
}
arr.push(itemObj);
} else {
arr.push(coerceScalar(line.trim()));
i++;
// also parse nested details if any
const nested = readBlock(expectedIndent + 2);
if (nested && Object.keys(nested).length) {
const last = arr[arr.length - 1];
if (typeof last === "object" && !Array.isArray(last)) {
Object.assign(last, nested);
} else {
arr[arr.length - 1] = nested;
}
}
}
}
continue;
}
// mapping entry key: value
if (!mode) mode = "obj";
if (mode !== "obj") err("Mixed sequence and mapping");
const kv = line.match(/^([^:]+):\s*(.*)$/);
if (!kv) err("Invalid mapping entry");
const key = kv[1].trim();
const val = kv[2];
if (val === "" || val === "|" || val === "|-") {
i++;
if (val === "|" || val === "|-") {
obj[key] = readLiteralBlock(expectedIndent + 2);
} else {
obj[key] = readBlock(expectedIndent + 2);
}
} else {
obj[key] = coerceScalar(val);
i++;
}
}
return mode === "arr" ? arr : obj;
}
i = 0;
const out = readBlock(0);
return out;
}
// ---------- Utilities ----------
const raf = () => new Promise((r) => requestAnimationFrame(r));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function triggerAll(el) {
if (!el) return;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("blur", { bubbles: true }));
}
function setValue(el, v) {
if (!el) return;
el.value = v == null ? "" : String(v);
triggerAll(el);
}
async function clickAndWait(el, waitMs = 35) {
if (!el) return;
el.click();
await raf();
if (waitMs) await sleep(waitMs);
}
function toArr(x) {
return !x ? [] : Array.isArray(x) ? x : [x];
}
function capFirst(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
const XP_BY_CR = {
"0": 10, "1/8": 25, "1/4": 50, "1/2": 100, "1": 200, "2": 450, "3": 700, "4": 1100,
"5": 1800, "6": 2300, "7": 2900, "8": 3900, "9": 5000, "10": 5900, "11": 7200, "12": 8400,
"13": 10000, "14": 11500, "15": 13000, "16": 15000, "17": 18000, "18": 20000, "19": 22000,
"20": 25000, "21": 33000, "22": 41000, "23": 50000, "24": 62000, "25": 75000, "26": 90000,
"27": 105000, "28": 120000, "29": 135000, "30": 155000,
};
function computeTypeLine(sb) {
const parts = [];
if (sb.size) parts.push(capFirst(String(sb.size).toLowerCase()));
if (sb.type) {
let t = sb.type;
if (sb.subtype) t += ` (${sb.subtype})`;
parts.push(t);
}
let typeLine = parts.join(" ");
if (sb.alignment) typeLine += `, ${sb.alignment}`;
return typeLine;
}
function parseAttackBasics(desc) {
const out = { type: "", toHit: "", reachOrRange: "", target: "" };
if (!desc) return out;
const typeM = desc.match(/^(Melee|Ranged)\s+Weapon\s+Attack:/i);
if (typeM) out.type = capFirst(typeM[1].toLowerCase());
const hitM = desc.match(/\+\s?(-?\d+)\s+to hit/i);
if (hitM) out.toHit = hitM[1];
const reachM = desc.match(/reach\s+(\d+\s*ft\.)/i);
const rangeM = desc.match(/range\s+([\d/]+\s*ft\.(?:\s*,\s*[\d/]+\s*ft\.)?)/i);
out.reachOrRange = reachM ? reachM[1] : rangeM ? rangeM[1] : "";
const targetM = desc.match(/,\s*([^.,]+target[^.,]*)\./i);
if (targetM) out.target = targetM[1].trim();
return out;
}
function damagePartsFromAction(a) {
const parts = [];
if (a.damage_dice) {
String(a.damage_dice)
.split("+")
.map((s) => s.trim())
.forEach((d) => parts.push({ dice: d, type: "" }));
}
if (a.damage_bonus && parts.length) {
const b = Number(a.damage_bonus);
if (!Number.isNaN(b)) parts[0].dice += b >= 0 ? `+${b}` : `${b}`;
}
if (a.desc) {
const order = [
"slashing","piercing","bludgeoning","fire","cold","lightning","thunder",
"acid","poison","psychic","radiant","necrotic","force",
];
const found = [];
order.forEach((kw) => {
if (new RegExp(`\\b${kw}\\b\\s+damage`, "i").test(a.desc)) found.push(kw);
});
for (let i = 0; i < parts.length && i < found.length; i++) parts[i].type = found[i];
if (parts[0] && !parts[0].type) {
const phys = ["slashing","piercing","bludgeoning"];
const p = phys.find((kw) => new RegExp(`\\b${kw}\\b\\s+damage`, "i").test(a.desc));
if (p) parts[0].type = p;
}
}
return { dmg1: parts[0] || null, dmg2: parts[1] || null };
}
function findNpcSheetRoots() {
// Jumpgate renders character sheets inside iframes with class 'ui-dialog'
// We look for any visible element that has the .is-npc root inside.
const roots = Array.from(document.querySelectorAll(".is-npc"));
return roots.filter((r) => r.offsetParent !== null);
}
// Find repeater controls and container within a specific NPC root
function repControls(root, group) {
const rc = root.querySelector(`.repcontrol[data-groupname="${group}"]`);
const container = root.querySelector(`.repcontainer[data-groupname="${group}"]`);
return { rc, container };
}
// Add a new row, return the .repitem element (not the template fieldset)
async function addRepeatingRow(root, group) {
const { rc, container } = repControls(root, group);
if (!rc || !container) return null;
const before = container.querySelectorAll(".repitem").length;
const addBtn = rc.querySelector(".repcontrol_add");
if (!addBtn) return null;
await clickAndWait(addBtn, 60);
// Wait until a new .repitem appears
for (let t = 0; t < 80; t++) {
const items = container.querySelectorAll(".repitem");
if (items.length > before) {
const row = items[items.length - 1];
// Open this row's edit panel so .npc_options inputs are present
const toggle = row.querySelector('button[class*="toggle_repeating_"][class*="_edit"]');
if (toggle) {
await clickAndWait(toggle, 60);
}
return row;
}
await raf();
}
return null;
}
// Clear existing rows by clicking delete on each .repitem (safer than removing nodes)
async function clearRepeating(root, group) {
const { container } = repControls(root, group);
if (!container) return;
const rows = Array.from(container.querySelectorAll(".repitem"));
for (const row of rows) {
const del = row.querySelector(".repcontrol_del");
if (del) {
await clickAndWait(del, 40);
} else {
row.remove();
await raf();
}
}
await raf();
}
// Fill attack subfields inside a .repitem row
async function fillAttackOptions(row, a) {
const edit = row; // .repitem root
// Ensure ATTACK options are visible (click the visible checkbox)
const attackCheckbox = edit.querySelector('.npc_options input[name="attr_attack_flag"][type="checkbox"]');
if (attackCheckbox && !attackCheckbox.checked) {
await clickAndWait(attackCheckbox, 80);
}
const basics = parseAttackBasics(a.desc || "");
setValue(edit.querySelector('select[name="attr_attack_type"]'), basics.type || "Melee");
setValue(edit.querySelector('input[name="attr_attack_tohit"]'), a.attack_bonus ?? basics.toHit ?? "");
setValue(edit.querySelector('input[name="attr_attack_range"]'), basics.reachOrRange || "");
setValue(edit.querySelector('input[name="attr_attack_target"]'), basics.target || "");
const parts = damagePartsFromAction(a);
if (parts.dmg1) {
setValue(edit.querySelector('input[name="attr_attack_damage"]'), parts.dmg1.dice);
setValue(edit.querySelector('input[name="attr_attack_damagetype"]'), parts.dmg1.type || "");
}
if (parts.dmg2) {
setValue(edit.querySelector('input[name="attr_attack_damage2"]'), parts.dmg2.dice);
setValue(edit.querySelector('input[name="attr_attack_damagetype2"]'), parts.dmg2.type || "");
}
}
// Apply traits/actions using .repitem rows and per-row edit mode
async function applyRepeatingSections(sb, npcRoot, opts) {
// Traits
if (opts.clearExisting) await clearRepeating(npcRoot, "repeating_npctrait");
for (const t of toArr(sb.traits)) {
const row = await addRepeatingRow(npcRoot, "repeating_npctrait");
if (!row) continue;
const edit = row; // .repitem root
setValue(edit.querySelector('input[name="attr_name"]'), t.name || "");
setValue(edit.querySelector('textarea[name="attr_description"]'), t.desc || "");
}
// Actions split
const split = splitActionsByKind(sb.actions);
async function fillActionGroup(items, groupName) {
if (opts.clearExisting) await clearRepeating(npcRoot, groupName);
for (const a of toArr(items)) {
const row = await addRepeatingRow(npcRoot, groupName);
if (!row) continue;
const edit = row;
setValue(edit.querySelector('input[name="attr_name"]'), a.name || "");
setValue(edit.querySelector('textarea[name="attr_description"]'), a.desc || "");
const looksAttack = /Weapon Attack:/i.test(a.desc || "") || (!!a.attack_bonus && Number(a.attack_bonus) !== 0);
if (looksAttack) {
await fillAttackOptions(row, a);
}
await raf();
}
}
await fillActionGroup(split.actions, "repeating_npcaction");
await fillActionGroup(split.bonus, "repeating_npcbonusaction");
await fillActionGroup(split.reactions, "repeating_npcreaction");
}
function splitActionsByKind(actions) {
const out = { actions: [], bonus: [], reactions: [] };
toArr(actions).forEach((a) => {
const nm = a?.name || "";
if (/\(reaction\)/i.test(nm)) out.reactions.push(a);
else if (/\(bonus action\)/i.test(nm)) out.bonus.push(a);
else out.actions.push(a);
});
return out;
}
async function applyStatblockToOpenNpc(sb, npcRoot, opts) {
// Basics
setValue(npcRoot.querySelector('input[name="attr_npc_name"]'), sb.name || "");
setValue(npcRoot.querySelector('input[name="attr_npc_type"]'), computeTypeLine(sb));
setValue(npcRoot.querySelector('input[name="attr_npc_ac"]'), sb.ac ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_actype"]'), "");
setValue(npcRoot.querySelector('input[name="attr_hp_max"]'), sb.hp ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_hpformula"]'), sb.hit_dice || sb.hitdice || "");
setValue(npcRoot.querySelector('input[name="attr_npc_speed"]'), sb.speed || "");
// Abilities
const stats = Array.isArray(sb.stats) ? sb.stats : [];
const [STR, DEX, CON, INT, WIS, CHA] = stats;
setValue(npcRoot.querySelector('input[name="attr_strength_base"]'), STR ?? "");
setValue(npcRoot.querySelector('input[name="attr_dexterity_base"]'), DEX ?? "");
setValue(npcRoot.querySelector('input[name="attr_constitution_base"]'), CON ?? "");
setValue(npcRoot.querySelector('input[name="attr_intelligence_base"]'), INT ?? "");
setValue(npcRoot.querySelector('input[name="attr_wisdom_base"]'), WIS ?? "");
setValue(npcRoot.querySelector('input[name="attr_charisma_base"]'), CHA ?? "");
// Saves (base fields)
const saveMap = {};
toArr(sb.saves).forEach((sv) => {
if (sv && typeof sv === "object") {
Object.keys(sv).forEach((k) => (saveMap[k.toLowerCase()] = sv[k]));
}
});
setValue(npcRoot.querySelector('input[name="attr_npc_str_save_base"]'), saveMap.strength ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_dex_save_base"]'), saveMap.dexterity ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_con_save_base"]'), saveMap.constitution ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_int_save_base"]'), saveMap.intelligence ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_wis_save_base"]'), saveMap.wisdom ?? "");
setValue(npcRoot.querySelector('input[name="attr_npc_cha_save_base"]'), saveMap.charisma ?? "");
// Damage/conditions
setValue(npcRoot.querySelector('input[name="attr_npc_vulnerabilities"]'), sb.damage_vulnerabilities || "");
setValue(npcRoot.querySelector('input[name="attr_npc_resistances"]'), sb.damage_resistances || "");
setValue(npcRoot.querySelector('input[name="attr_npc_immunities"]'), sb.damage_immunities || "");
setValue(npcRoot.querySelector('input[name="attr_npc_condition_immunities"]'), sb.condition_immunities || "");
// Senses, languages
setValue(npcRoot.querySelector('input[name="attr_npc_senses"]'), sb.senses || "");
setValue(npcRoot.querySelector('input[name="attr_npc_languages"]'), sb.languages || "—");
// CR/XP/PB
const crStr = sb.cr != null ? String(sb.cr) : "";
setValue(npcRoot.querySelector('input[name="attr_npc_challenge"]'), crStr);
const xp = XP_BY_CR[crStr] ?? (typeof sb.xp === "number" ? sb.xp : "");
setValue(npcRoot.querySelector('input[name="attr_npc_xp"]'), xp);
setValue(npcRoot.querySelector('input[name="attr_npc_pb"]'), sb.pb ?? 0);
// Token size
const tokenSizeMap = { tiny: "0.5", small: "1", medium: "1", large: "2", huge: "3", gargantuan: "4" };
const ts = sb.size ? tokenSizeMap[String(sb.size).toLowerCase()] || "1" : "1";
setValue(npcRoot.querySelector('input[name="attr_token_size"]'), ts);
// Traits
await applyRepeatingSections(sb, npcRoot, opts);
}
// ---------- Button injection ----------
function ensureButtonForRoot(npcRoot) {
if (!npcRoot) return;
if (npcRoot.querySelector("#paste-statblock-yaml-btn")) return;
// Find the NPC OPTIONS title row within this root
const optionsHeader =
npcRoot.querySelector('.npc_options .row.title') ||
npcRoot.querySelector('.npc_options .row .title') ||
npcRoot.querySelector('.npc_options');
if (!optionsHeader) return;
const wrap = document.createElement("div");
wrap.style.display = "inline-flex";
wrap.style.gap = "6px";
wrap.style.marginLeft = "8px";
wrap.style.flexWrap = "wrap";
const btn = document.createElement("button");
btn.id = "paste-statblock-yaml-btn";
btn.className = "btn";
btn.textContent = "Paste statblock YAML";
const label = document.createElement("label");
label.style.display = "inline-flex";
label.style.alignItems = "center";
label.style.gap = "4px";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.id = "clear-existing";
const span = document.createElement("span");
span.textContent = "Clear existing";
label.appendChild(cb);
label.appendChild(span);
wrap.appendChild(btn);
wrap.appendChild(label);
optionsHeader.appendChild(wrap);
btn.addEventListener("click", async () => {
try {
const yaml = prompt("Paste Obsidian statblock YAML:");
if (!yaml) return;
const sb = loadYAML(yaml);
await applyStatblockToOpenNpc(sb, npcRoot, { clearExisting: cb.checked });
alert("Statblock applied.");
} catch (e) {
console.error(e);
alert("Parse/apply error: " + e.message);
}
});
}
function injectButtons() {
const roots = findNpcSheetRoots();
roots.forEach(ensureButtonForRoot);
}
const obs = new MutationObserver(() => {
injectButtons();
});
obs.observe(document.documentElement, { subtree: true, childList: true });
injectButtons();
})();
@crueber
Copy link
Author

crueber commented Aug 27, 2025

Were they vibecoded? You bet'ja. GPT5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment