I've been doing some testing in conjunction with Finderski's SW tabbed sheet group. It's still a very early draft, but this should work in most cases.IF you have issues, let me know and I'll make changes. // Unofficial Savage Worlds Statblock Importer // by Nic B. 16/05/19 // based on work by Jason.P & Pelwer // // INSTRUCTIONS // 1. Find yourself a SW stat-block // 2. Copy the stat block from *Name* on down // 3. Paste the stat block into the GM Notes Section of a token in your roll20 campaign. // (if you're having difficulties, use CTRL+SHIFT+V on Windows or Option+Shift+Command+V on Mac to paste *without* formatting) // 4. Make sure the creature's name is on its own line, and is prefixed with DD or [WC] if it is a wild card. // 5. Select the token // 6. In the chat box, type the command "!SW-Import". // var SWImporter = SWImporter || ( function () { const vocal = 1; const elementTypes = { 'Attributes' : { delimiters: [',',' '], }, 'Skills' : { name: 'Skills', delimiters: [',',' '], }, 'Pace' : {}, 'Parry' : {}, 'Toughness' : {}, 'Edges' : { delimiters: [','], repeating: 'edge', }, 'Hindrances' : { delimiters: [','], repeating: 'hindrance', }, 'Special Abilities' : { delimiters: ['~',':'], repeating: 'attribute', }, 'Powers' : { delimiters: [','], repeating: 'power', }, 'PowerPoints' : {}, 'Gear' : {}, }; const wildcardPrefixes = [ "DD", "[WC]", ]; const entityMap = { '\<span .*?\>':'', '\<\/span\>':'', '\<p\>':'', '\<\/p\>':'', '\<br\>':'', '\ \;':'~', ' ':'~', ';':'', '-':'', '−':'-', '′′|“':'\"', '′':'\'', '\\s+':' ', '[^\x00-\x7F]+':'~', 'Common Knowledge':'CommonKnowledge', 'Weird Science':'WeirdScience', 'Native Language':'Language', 'Academics':'AcademicsSkill', 'Power Points':'PowerPoints', 'T\\s*o\\s*u\\s*g\\s*h\\s*n\\s*e\\s*s\\s*s\\s*':'Toughness', 'Language \\(\(\\w+\) \(.*?\)\\)':'Language($1) $2', }; let character = {}; const handleInput = (message) => { const messageType = message.type; const messageArguments = message.content.split(" --"); const selectedObject = retrieveSelectedObject(message.selected); if (messageType !== "api") return; if (messageArguments[0] !== "!SW-Import") return; if (messageArguments[1] === "help") { showHelp(); return; } if (!selectedObject) { sendChat("ERROR", "No Token Selected."); return; } if (selectedObject.get('subtype') !== 'token') { sendChat("ERROR","Must select a token, not a drawing or a card."); return; } const inputNotes = selectedObject.get('gmnotes'); importCharacter(inputNotes); }; const retrieveSelectedObject = (selected) => { if (selected && selected.length > 0) { return getObj('graphic', selected[0]._id); } else { return false; } }; const importCharacter = (inputNotes) => { const inputSanitized = sanitizeInput(inputNotes); const inputArray = identityKeywords(inputSanitized).split("@"); parseElements(inputArray); createCharacter(); }; const sanitizeInput = (inputNotes) => { const mapFindReplace = Object.entries(entityMap); let inputSanitized = unescape(inputNotes).replace(/\<\/p\>/,'@'); // cleans up the percent coding, adds an @ to the end of the name. mapFindReplace.forEach(([find, replace]) => { const regularExpression = new RegExp(find,"g"); inputSanitized = inputSanitized.replace(regularExpression,replace); }); return inputSanitized; }; const identityKeywords = (inputSanitized) => { const entityList = Object.keys(elementTypes).map(x => `(${x}:)`); // prepares const elementTypes for regular expression. i.e. Attributes => (Attributes:) let inputKeyworded = inputSanitized; entityList.forEach( (entity) => { regularExpression = new RegExp(entity,"g"); inputKeyworded = inputKeyworded.replace(regularExpression,'@$1'); }); return inputKeyworded; }; const parseElements = (inputArray) => { const firstLine = inputArray.shift(); character.wildCard = identifyWildCardPrefix(firstLine); character.name = firstLine.substring(character.wildCard.length).trim(); inputArray.forEach( (statblockElement) => { const elementType = getElementType(statblockElement); if(elementType) { const delimiters = (elementTypes[elementType].delimiters !== undefined) ? elementTypes[elementType].delimiters : undefined; if(elementType.length === 0) { character.description += statblockElement; return; } statblockElement = (elementType && statblockElement !== 'undefined') ? statblockElement.substring(elementType.length+1).trim() : statblockElement; character[elementType] = explodeElementToArray(statblockElement,delimiters); } }); }; const identifyWildCardPrefix = (firstLine) => { let wildCard = ""; wildcardPrefixes.forEach((prefix) => { wildCard = (firstLine.indexOf(prefix) !== -1) ? prefix : wildCard; }); return wildCard; }; const getElementType = (arrayItem) => { let elementType = ""; Object.keys(elementTypes).some((type) => { elementType = (arrayItem.indexOf(`${type}: `) !== -1) ? type : elementType; }); return elementType; }; const getDelimiters = (elementType) => { let elementDelimiters = []; Object.entries(elementTypes).forEach(([key,values]) => { elementDelimiters = (key === elementType) ? values.delimiters : elementDelimiters; }); if (elementDelimiters !== undefined) {return elementDelimiters;} return sendChat("ERROR",`Invalid element type. ${elementType}`); } const explodeElementToArray = (input, delimiters) => { if(!delimiters || delimiters.length === 0) return input; const output = input.trim() .split(delimiters[0]) .map(x => explodeElementToArray(x, delimiters.slice(1))); return output; }; const convertDiceToNumbers = (value) => { return value.replace(/d(.*)$/,"$1"); } const createCharacter = () => { if (characterSheetExists(character.name)) { sendChat("ERROR","This character already exists."); return; } characterObject = initializeCharacterSheet(character.name); character.id = characterObject.get('_id'); if(character.wildCard) { createAttribute('is_npc',0,character.id); } else { createAttribute('is_npc',1,character.id); } Object.entries(elementTypes).forEach(([key,values]) => { if(character[key]) { if(typeof character[key] === 'string') { createAttribute(key,character[key],character.id); } else if(Array.isArray(character[key])) { arrayToAttributes(character[key],character.id,key,values.repeating); debug(`Key: ${key}`); if(key === 'Skills') { character[key].forEach((item) => createAttribute(`static${item[0].toLowerCase()}`,'on',character.id)); } } else { debug(`ERROR ${key} : ${typeof character[key]} : ${character[key]}`); } } }); }; const characterSheetExists = (characterName) => { const querySheet = findObjs({ _type: "character", name: characterName }); if (querySheet.length > 0 ) { return true; } return false; } const initializeCharacterSheet = (characterName) => { const characterObject = createObj("character", { name: characterName, archived: false, }); return characterObject; } const createAttribute = (key,value,characterID) => { if(key === 'undefined' || value === 'undefined' || characterID === 'undefined') { sendChat(`ERROR`,`Error, value missing. Key: ${key} Value: ${value} characterID: ${characterID}`); return; } createObj("attribute", { name: key.toLowerCase(), current: value, characterid: characterID, }); }; const arrayToAttributes = (array,characterID,repeatingName,repeatingAttribute) => { if (array[0] !== undefined && Array.isArray(array[0])) { array.forEach((item) => { if (item[1].indexOf('+') != -1) { const dice = item[1].split("+")[0]; const mod = item[1].split("+")[1]; createAttribute(item[0],convertDiceToNumbers(dice),characterID); createAttribute(`${item[0]}mod`,mod,characterID); } else { createAttribute(item[0],convertDiceToNumbers(item[1]),characterID); } }); return; } if (!repeatingName) { sendChat("ERROR","Element is not returning an array or a repeating section."); return; } else { array.forEach((item) => { const UUID = `repeating_${repeatingName.trim()}_${generateUUID()}_${repeatingAttribute.trim()}`; createAttribute(UUID,item.trim(),characterID); }); } }; const generateUUID = () => { let a = 0; let b = []; let c = (new Date()).getTime() + 0 let d = c === a; a = c; for (var e = new Array(8), f = 7; 0 <= f; f--) { e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".charAt(c % 64); c = Math.floor(c / 64); } c = e.join(""); if (d) { for (f = 11; 0 <= f && 63 === b[f]; f--) { b[f] = 0; } b[f]++; } else { for (f = 0; 12 > f; f++) { b[f] = Math.floor(64 * Math.random()); } } for (f = 0; 12 > f; f++){ c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".charAt(b[f]); } return c; }; const debug = (text) => { if (vocal === 1) { log(`DEBUG: ${text}`); } }; const registerEventHandlers = () => { on('chat:message', handleInput); debug("Event Handlers Registered"); }; return { RegisterEventHandlers: registerEventHandlers }; })(); on("ready",() => { SWImporter.RegisterEventHandlers(); }); Disclaimer: This is entirely third party and unofficial, and is in no way supported or endorsed by Roll20.