Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

Any Mods to copy Spells from sheet to sheet? (5E D&D)

Title basically asks the question. :)
1660753537
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
There are some options like that for the Shaped Sheet. It has a built-macro that lets you add and subtract SRD spells very quickly, and it can be accessed via macro to load or unload an entire spellbook. Example But AFAIK, nothing like that exists for the D&D 5th Edition by Roll20 Sheet.
1660824943

Edited 1660871451
Oosh
Sheet Author
API Scripter
You can give this script a go, though it's not really tested, or polished, or much of anything. Usage is !copyRepSec --sel <character_id> --tar <character_id> --section <section name> [--name <section name attribute>]     --sel is the source character id     --tar is the target character(s), comma delimited if you want to use more than one     --section is the section name, in between 'repeating' and the rowId, so for 5e things like attack, traits, inventory, spell-7     --name is for supplying the name attribute for the given section (for display purposes). This should be handled automatically for 5e, but for other sheets, if you were looking in a section where the row name was stored in repeating_shoulderpads_-abcdefgh1234567890_padsname , you would supply --name padsname For the 5e sheet, there's a shortcut section name, 'spells', which will automatically grab all 10 spell repeating sections. So the simplest way to run it for spells in 5e, is select the source character and type: !copyrepsec --sel @{selected|character_id} --tar @{target|character_id} --section spells Then click the buttons to copy each spell. The target id(s) are baked into the buttons, so you'll need to run the above macro again if you want to copy to a different sheet. Like I said, barely tested, so.... maybe make copies of sheets before trying it out :) The script: /* globals on, sendChat, getObj, findObjs, createObj */ on('ready', () => { const scriptName = 'copyRepSec'; const sectionNameAttributes = { spells: 'spellname', tool: 'toolname', inventory: 'itemname', attack: 'atkname', } const sectionShortcuts = { spells: ['spell-cantrip', 'spell-1', 'spell-2', 'spell-3', 'spell-4', 'spell-5', 'spell-6', 'spell-7', 'spell-8', 'spell-9'], } const rx = { id: /-[0-z_-]{19}/, strictId: /^-[0-z_-]{19}$/, rowId: /_(-[0-z-]{19})_/, repeatingSectionName: /(repeating_.+)_-[0-z_-]{19}/, } const buttonStyle = `background: none; display: inline-block; margin: 0.25rem 0.5rem 0.25rem 0; color: blue; border: 1px solid blue; border-radius: 4px;`; const attributeTasks = { spells: { spelloutput: { condition: (attrObj) => /attack/i.test(attrObj.get('current')), task: async (attr) => { // attr.setWithWorker({ current: 'SPELLCARD' }); // await timeout(1000); // attr.setWithWorker({ current: 'ATTACK' }); } }, ['details-flag']: { condition: () => true, task: async (attr) => { attr.setWithWorker({ current: 0 }) } } } } const timeout = async (ms) => new Promise((res) => setTimeout(()=>res(), ms)) const copyRepeatingRow = async (characterId, section, repeatingRowArray) => { const taskQueue = [], newRowId = generateUID().replace(/_/g, 'z'), taskCategory = Object.entries(sectionShortcuts).reduce((out, sec) => sec[1].includes(section) ? sec[0] : out, section); console.log(`Copying ${repeatingRowArray.length} attributes from row...`); await Promise.all(repeatingRowArray.map(async (attr) => { const newAttribute = { characterid: characterId, name: `repeating_${section}_${newRowId}_${attr.name}`, current: attr.current, max: attr.max }; const newAttr = createObj('attribute', newAttribute); if (attributeTasks[taskCategory] && attributeTasks[taskCategory][attr.name]) { const taskRequired = attributeTasks[taskCategory][attr.name].condition(newAttr); if (taskRequired) { taskQueue.push({ task: attributeTasks[taskCategory][attr.name].task, attribute: newAttr }); } } await timeout(1); return 1; })); console.log(`Finished writing attributes...`); await timeout(50); if (taskQueue.length) { console.info(`Executing ${taskQueue.length} tasks...`); taskQueue.forEach(t => t.task(t.attribute)); } } const getRepeatingRow = (characterId, sectionName, rowId) => { const allAttrs = findObjs({ type: 'attribute', characterid: characterId, }), rxRow = new RegExp(`^repeating_${sectionName}_${rowId}_`, 'i'); return allAttrs .filter(a => rxRow.test(a.get('name'))) .map(a => ({ name: a.get('name').replace(rxRow, ''), current: a.get('current'), max: a.get('max') })); } const buildTemplate = (sections, commands) => { const templateRows = Object.entries(sections).map(kv => { return kv[1].length ? `{{${kv[0]}=${kv[1].reduce((out, row) => out += `[${row.name.trim() || '???' }](!${scriptName} --copy --sel ${commands.selected} --tar ${commands.targets.join(', ')} --sec ${kv[0]} --row ${row.rowId}" class="copyrepsec" style="${buttonStyle})`, '')}}}` : `{{${kv[0]}=No entries found}}`; }); return `&{template:default} {{name=${commands.sectionName} => ${commands.nameAttribute}}} ${templateRows.join('')}`; } const getSectionRows = (characterId, sectionName, nameAttribute) => { const sectionArray = sectionShortcuts[sectionName] || [ sectionName ], allAttrs = findObjs({ type: 'attribute', characterid: characterId }), output = sectionArray.reduce((out, sec) => { const rxRowNames = new RegExp(`repeating_${sec}_-[0-z_-]{19}_${nameAttribute}`, 'i'), sectionRowNameAttributes = allAttrs.filter(a => rxRowNames.test(a.get('name'))); out[sec] = sectionRowNameAttributes.reduce((out, attr) => [ ...out, { name: attr.get('current'), rowId: (attr.get('name').match(rx.rowId)||[])[1] } ], []); return out; }, {}); // console.info(output); return output; } const checkCharacterId = (id) => (rx.strictId.test(id) && getObj('character', id)); const checkRepeatingSection = (sectionName, characterId) => { const char = characterId ? getObj('character', characterId) : null; if (!char) return false; if (Object.keys(sectionShortcuts).includes(sectionName)) return true; const attrs = findObjs({ type: 'attribute', characterid: characterId }) || [], rxSection = new RegExp(`repeating_${sectionName}_`, 'i'); return attrs.filter(v => rxSection.test(v.get('name'))).length; } const parseCommands = (cliString) => { if (!cliString) return null; const commands = cliString.split(/\s*--\s*/g).filter(v=>v); const output = commands.reduce((out, command) => { const [ , option, parameterString ] = (command.match(/(\w+)\s*(.*)/)||[]), parameters = (parameterString||'').trim().split(/\s*,\s*/g); if (option) { if (/^sel/i.test(option) && checkCharacterId(parameters[0])) { return { ...out, selected: parameters[0] }; } else if (/^tar/.test(option)) { return { ...out, targets: parameters.filter(v => checkCharacterId(v)) }; } else if (/^sec/.test(option)) { return { ...out, sectionName: parameters[0] }; } else if (/^name/.test(option) && parameters[0]) { return { ...out, nameAttribute: parameters[0] } } else if (/^copy/.test(option)) return { ...out, copy: true } else if (/^row/.test(option) && rx.strictId.test(parameters[0])) return { ...out, sourceRow: parameters[0] } else return out; } // console.log(option, parameters); }, { copy: false, sourceRow: '', selected: '', targets: [], sectionName: '', nameAttribute: '' }); output.nameAttribute = output.nameAttribute || sectionNameAttributes[output.sectionName] || 'name'; return output; } const cleanMessageName = (name) => `${name}`.replace(/\s*\(GM\)$/, ''); on('chat:message', (msg) => { if (msg.type === 'api' && /^!copyRepSec\s+[\S]+/i.test(msg.content)) { const cliString = (msg.content.match(/^!copyrepsec\s+(.+)/i)||[])[1], commands = parseCommands(cliString), validSection = checkRepeatingSection(commands.sectionName, commands.selected), msgWho = cleanMessageName(msg.who); if (commands.copy) { const errorMessage = !commands.selected ? `${scriptName} error copying row: no selected character ID passed, or bad character ID` : !commands.targets.length ? `${scriptName} error copying row: no target character ID(s) passed, or bad character ID(s)` : !validSection ? `${scriptName} error copying row: no repeating section name passed, or no attributes found in repeating section "${commands.sectionName}"` : !commands.sourceRow ? `${scriptName} error copying row: no repeating section row passed, or bad rowId passed.` : null; if (errorMessage) { console.log(commands); sendChat(scriptName, `/w "${msgWho}" ${errorMessage}`); return; } // sendChat(scriptName, `/w "${msgWho}" copying ${commands.sectionName} => ${commands.sourceRow}`); const sourceRowArray = getRepeatingRow(commands.selected, commands.sectionName, commands.sourceRow); // console.info(sourceRowArray); commands.targets.forEach(t => copyRepeatingRow(t, commands.sectionName, sourceRowArray)); } else { const errorMessage = !commands.selected ? `${scriptName} error creating list: no selected character ID passed, or bad character ID` : !commands.targets.length ? `${scriptName} error creating list: no target character ID(s) passed, or bad character ID(s)` : !validSection ? `${scriptName} error creating list: no repeating section name passed, or no attributes found in repeating section "${commands.sectionName}"` : null; if (errorMessage) { console.log(commands); sendChat(scriptName, `/w "${msgWho}" ${errorMessage}`); return; } const rowEntries = getSectionRows(commands.selected, commands.sectionName, commands.nameAttribute), chatMessage = buildTemplate(rowEntries, commands); sendChat(scriptName, `/w "${msgWho}" ${chatMessage}`); } } }); const randomInt = (range=100, depth=32) => { const max = range * 2**depth; let random; do { random = Math.floor(Math.random() * 2**depth) } while (random >= max); return random % range; } const generateUID = (numIds = 1) => { let output = [], key = ''; const chars = '-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'; let ts = Date.now(); for (let i = 8; i > 0; i--) { output[i] = chars.charAt(ts % 64), ts = Math.floor(ts / 64) } for (let j = 0; j < 12; j++) { output.push(chars.charAt(randomInt(64))) } key = output.join(''); if (numIds > 1) { numIds = Math.min(32, numIds); output = Array(numIds).fill().map((v,i) => { let lastChar = chars[(chars.indexOf(key[19])+i)%64]; return `${key.slice(0,18)}${lastChar}`; }); return output; } else return key; } });
Oosh said: You can give this script a go, though it's not really tested, or polished, or much of anything. Nice job, I will certainly try it out!
1660827033

Edited 1660827454
Unfortunately, it isn't working.  It creates the list of buttons in chat with each spell from source sheet, but clicking a button only creates an empty spell on the target sheet (in the right spell level).  But, doesn't copy the text over. Edit: oddly, when I uncommented the two debugging lines (shown below), it started to work. sendChat(scriptName, `/w "${msgWho}" copying ${commands.sectionName} => ${commands.sourceRow}`); console.info(sourceRowArray); 2nd edit: on the new character sheet, when I "roll" the spell (cast the spell), it actually rolls it off of the original sheet (using its DC, attributes, etc.) instead of the stats of the new sheet.  Still needs some work. :)
1660828010

Edited 1660828385
Oosh
Sheet Author
API Scripter
Ah yep, forgot about the linking of the spells and attacks section. The blank entries might have something to do with lazy loading, not sure. I'll have a look... Oh, and a temporary fix for attack linking is, after copying the spell, switch the output from ATTACK => SPELLCARD => ATTACK. This should fire the sheetworker and create a new linked attack on the right sheet.
1660834853

Edited 1660834887
Oosh
Sheet Author
API Scripter
The phantom rows were just me being an idiot - forgot rowId's can't have underscores in them. That's fixed above. The linked attack for the spells is trickier. There's a commented-out method about 30 lines down in attributeTasks=>spells=>spelloutput=>task It does successfully fire and create an entry in the attacks section, but it's failing to pull the data from the spell. I've no idea why it works from a click on the sheet, but not from an API event. FWIW it also fails with chatsetattr, so I'm not sure much can be done about it, beyond copying the whole sheetworker.
1660841654
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Doesn't ChatSetAttr have a usesheetworkers option? Or is that a different concern?
Works for me. Very nice! I have an in game sheet with tons of custom spells for my players to draw from. This will save lots of tedious copy and paste time. I do have a question about where I can add a space to the names generated? I use Stylus to make the buttons smaller which clumps them together, and couldn't seem to figure out where to add a space or an '|' to separate them within the script.
1660870157

Edited 1660870723
Oosh
Sheet Author
API Scripter
keithcurtis said: Doesn't ChatSetAttr have a usesheetworkers option? Or is that a different concern? It sure does, both scripts are triggering an event, and the sheetworker is reacting. But if the event comes from a sandbox action, the attack entry is blank. I can only assume the sandbox triggered event is missing a key in the event details that the sheetworker is expecting. Not quite in the mood to get myself that covered in Roll20 viscera, I'll investigate that another time :) Nox said: Works for me. Very nice! I have an in game sheet with tons of custom spells for my players to draw from. This will save lots of tedious copy and paste time. I do have a question about where I can add a space to the names generated? I use Stylus to make the buttons smaller which clumps them together, and couldn't seem to figure out where to add a space or an '|' to separate them within the script. Yeah sorry, I didn't prettify the output at all. Added some very basic styling to it - there's a buttonStyle variable, about 4 down from the top. Feel free to chuck your own CSS string in there. There's also a class on the buttons so you can select them with ".userscript-copyrepsec". Bear in mind the chatbar has a strict and outdated CSS sanitizer for inline styles sent from sendChat, so your Stylus code may not work if it contains any CSS keys that were 'new' any time in the last decade...
1660876020

Edited 1660876076
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
This could be very handy for creating spell scrolls o r lists of equipment.
It takes a little bit of work to setup but I've found the SpellMaster  script works well for things like this