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

A script to copy an item between repeating sections of different characters: Is this possible?

I would like to copy an item in a repeating section of one character's sheet to the identical repeating section on another character's sheet. Is it theoretical possible to write an API script that can do this? Can the API pick up the Information of one item in a repeating section and write it to another character sheet? I tried to find an answer in the wiki, but I'm new to the API and couldn't understand all of it. If this is possible, this could be used to move feats, spells, weapons, equipment and a lot of other things from character to character. Thus these things would not have to be entered more than once.
1588535080
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
This is theoretically possible. You could do it with ChatSetAttr with a lot of work and some limitations, but I don't know of a script which does this out of the box.
I do this using ChatSetAttr to move items from a "compendium" character to player characters. It requires at least 2 macros for every type of item, though: one to move the attribute names for an item into another set of attributes, then one to create a new item on the player sheet using those attributes. I've been working on a post to share how to do just that, but it's been quite complicated :p
1588573546
GiGs
Pro
Sheet Author
API Scripter
This sounds like a good job for a dedicated script. I might take a stab at this tomorrow if Aaron hasnt seen the post and written up a script to do it during his tea break.
1588598102
The Aaron
Roll20 Production Team
API Scripter
HAHAHAHA.. This is slightly more complicated than a tea break, but I have started writing something.  I got bogged down in the interface but I'm going to try and finish it up tonight...
1588620740
GiGs
Pro
Sheet Author
API Scripter
That's my Invoke God of Scripts ability used up for the week, for the good of the community!
1588621646
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Thank goodness for high level Clerics.
1588625088
The Aaron
Roll20 Production Team
API Scripter
BWAHAHAHAHAH
I'm excited to see what you come up with! My method with ChatSetAttr is a bit messy lol
1588626411
The Aaron
Roll20 Production Team
API Scripter
I think this is the command structure I've settled on: !cp-action --src|name frag of char --attr|section|key|value --attr|section|index --dst|name frag --dst|name frag So you might copy the row with name="longsword" to 3 characters with: !cp-action --src|bob the slayer --attr|weapon|name|longsword --dst|julie --dst|sam --dst|taco bot 3000 or the row at line 3 with: !cp-action --src|bob the slayer --attr|weapon|3 --dst|julie --dst|sam --dst|taco bot 3000 That sort of thing.  Just need to finish writing it.
1588627362
GiGs
Pro
Sheet Author
API Scripter
What about allowing to copy to all characters who are connected to a group of selected tokens? When I was thinking about it last night, I was thinking of having a src character explicitly stated, but allow you to select a group of characters. If this is being used like a compendium, the source character is likely to be fixed, but the destination characters might vary in number. Also, does your structure allow for copying an entire repeating section? That could be handy for setting up new characetrs with defined sets of stats in a repeating section.
1588641614

Edited 1588695057
The Aaron
Roll20 Production Team
API Scripter
Not in version 1!   =D Speaking of, here it is: on('ready', ()=>{ const generateUUID = (() => { let a = 0; let b = []; return () => { let c = (new Date()).getTime() + 0; let f = 7; let e = new Array(8); let d = c === a; a = c; for (; 0 <= f; f--) { e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".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 += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]); } return c; }; })(); const generateRowID = () => generateUUID().replace(/_/g, "Z"); const attrLookup = (character,name,caseSensitive) => { let match=name.match(/^(repeating_.*)_\$(\d+)_.*$/); if(match){ let index=match[2], attrMatcher=new RegExp(`^${name.replace(/_\$\d+_/,'_([-\\da-zA-Z]+)_')}$`,(caseSensitive?'':'i')), createOrderKeys=[], attrs=_.chain(findObjs({type:'attribute', characterid:character.id})) .map((a)=>{ return {attr:a,match:a.get('name').match(attrMatcher)}; }) .filter((o)=>o.match) .each((o)=>createOrderKeys.push(o.match[1])) .reduce((m,o)=>{ m[o.match[1]]=o.attr; return m;},{}) .value(), sortOrderKeys = _.chain( ((findObjs({ type:'attribute', characterid:character.id, name: `_reporder_${match[1]}` })[0]||{get:_.noop}).get('current') || '' ).split(/\s*,\s*/)) .intersection(createOrderKeys) .union(createOrderKeys) .value(); if(index<sortOrderKeys.length && _.has(attrs,sortOrderKeys[index])){ return attrs[sortOrderKeys[index]]; } return; } return findObjs({ type:'attribute', characterid:character.id, name: name}, {caseInsensitive: !caseSensitive})[0]; }; const keyFormat = (text) => `${text}`.toLowerCase().replace(/\s+/g,''); const matchKey = (keys,subject) => subject && !_.isUndefined(_.find(keys,(o)=>(-1 !== subject.indexOf(o)))); const getCharsForFragments = (frag) => { let keys = (Array.isArray(frag) ? frag : [frag]).map(keyFormat); return findObjs({type:'character'}) .filter(c=>matchKey(keys,keyFormat(c.get('name')))); }; const getRowIdsForOps = (c,op) => { if(op.hasOwnProperty("index")){ // find by offset let r = new RegExp(`^(repeating_${op.section})_([^_]*)_(.*)$`,'i'); let attr = findObjs({ type: 'attribute', characterid: c.id }).find(a=>r.test(a.get('name'))); if(attr){ let parts = attr.get('name').match(r); let lookupName = `${parts[1]}_$${op.index}_${parts[3]}`; let attr2 = attrLookup(c, lookupName); if(attr2){ let parts2 = attr2.get('name').match(r); op.rowid = parts2[2]; } } } else { // find by value let r = new RegExp(`^(repeating_${op.section})_([^_]*)_${op.attr}$`,'i'); let attr = findObjs({ type: 'attribute', characterid: c.id }) .find(a=>r.test(a.get('name')) && ( keyFormat(a.get('current')).indexOf(keyFormat(op.value)) !== -1 || keyFormat(a.get('max')).indexOf(op.value) !== -1) ); if(attr) { let parts = attr.get('name').match(r); op.rowid = parts[2]; } } return op; }; const simpleObj = (o) => JSON.parse(JSON.stringify(o)); const doActionCopy = (srcC,op,dstChars) => { let r = new RegExp(`^repeating_${op.section}_${op.rowid}_`,'i'); let attrs = findObjs({ type: 'attribute', characterid: srcC.id }).filter(a=>r.test(a.get('name'))); dstChars.forEach(c=>{ let rowid = generateRowID(); attrs.forEach(a=>{ let a2 = simpleObj(a); createObj('attribute',{ name: a2.name.replace(op.rowid,rowid), characterid: c.id, current: a2.current, max: a2.max }); }); }); }; // !cp-action --src|name frag of char --attr|section|key|value --attr|section|index --dst|name frag --dst|name frag on('chat:message', msg=>{ if('api'==msg.type && /^!cp-action(\b\s|$)/i.test(msg.content) && playerIsGM(msg.playerid)){ let who = (getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname'); let args = msg.content.split(/\s+--/); let srcChar; let dstChars=[]; let attrOps=[]; let notes =[]; args.slice(1).forEach(a=>{ let cmd = a.split(/\|/); switch(cmd[0].toLowerCase()){ case 'attr': switch(cmd.length){ case 3: attrOps.push({ section: cmd[1], index: parseInt(cmd[2])||1 }); break; case 4: attrOps.push({ section: cmd[1], attr: cmd[2], value: cmd.slice(3).join('|').toLowerCase() }); break; } break; case 'src': srcChar = cmd.slice(1).join('|'); break; case 'dst': dstChars.push(cmd.slice(1).join('|')); break; default: notes.push(`Don't know how to handle: <code>--${a}</code>`); } }); if(!srcChar || (0 === dstChars.length) || ( 0 === (attrOps.length))){ if(!srcChar){ notes.push(`No source character specified (use <code>--src|CHARACTER</code>).`); } if(0 === dstChars.length){ notes.push(`No destination characters specified (use <code>--dst|CHARACTER</code>).`); } if( 0 === attrOps.length){ notes.push(`No attributes specified (use <code>--attr|SECION|KEY|VALUE</code> or <code>--attr|SECION|INDEX</code>).`); } sendChat('',`/w "${who}" <div><ul>${notes.map(n=>`<li>${n}</li>`).join('')}</ul></div><div>Use one of: <ul><li><code>!cp-action --src|CHARACTER --attr|SECTION|KEY|VALUE --dst|CHARACTER</code></li><li><code>!cp-action --src|CHARACTER --attr|SECTION|NUMBER --dst|CHARACTER</code></li></ul></div>`); return; } // find src char let cpSrc = getCharsForFragments(srcChar)[0]; if(!cpSrc) { sendChat('',`/w "${who}" <div>Cannot find source Character for: <code>${srcChar}</code></div>`); return; } // find dst chars let cpDst = getCharsForFragments(dstChars); if(0 === cpDst.length) { sendChat('',`/w "${who}" <div>Cannot find Destination Character for: <code>${dstChars.join(', ')}</code></div>`); return; } // find rowids let rowIds = attrOps.map((op)=>getRowIdsForOps(cpSrc,op)); rowIds.forEach(o=>{ if( ! o.hasOwnProperty('rowid') ){ notes.push(`Failed to find a match for <code>--attr|${o.section}|${ o.hasOwnProperty('index') ? `${o.index}` : `${o.attr}|${o.value}`}</code>.`); } }); if(notes.length){ sendChat('',`/w "${who}" <div><ul>${notes.map(n=>`<li>${n}</li>`).join('')}</ul></div>`); } // do action copies rowIds.forEach(row => doActionCopy(cpSrc, row, cpDst)); } }); }); Edit: added better error messages. Edit: better key format handling.
First of all, thank you. Unfortunately it doesn't work for me and of course 99% is up to me. I have a character named Eran. His weapons and attacks can be found on the OGL sheet under (name/attacks). One attack is longbow. I want to copy this to Boran. So I use: !cp-action --src|Eran --attr|Weapon|name|Longbow --dst|Boran Is that about right? Because absolutely nothing happens, except that the message  Use one of:     !cp-action --src|CHARACTER --attr|SECTION|KEY|VALUE --dst|CHARACTER     !cp-action --src|CHARACTER --attr|SECTION|NUMBER --dst|CHARACTER appears. Best regards Sebastian
1588666066

Edited 1588666184
GiGs
Pro
Sheet Author
API Scripter
Should  !cp-action --src|Eran --attr|Weapon|name|Longbow --dst|Boran be !cp-action --src|Eran --attr|weapon|name|Longbow --dst|Boran ? Probably not because I see toLowerCase in aaron's code, but it's always worth checking that particular issue with repeating section names. If the sheet itself has a capital letter there, there could be a problem.
This is great! I can use it when I use a number for the row like this: !cp-action --src|Races --attr|perks|0 --dst|Test But when I use a key it does just nothing. !cp-action --src|Races --attr|perks|name|Darkvision --dst|Test
1588685643
The Aaron
Roll20 Production Team
API Scripter
Ok, I edited the above to include much better error feedback.  It should hopefully point to the issue with Sebastian's and Markus's commands.
Hello, where is a list of categories and their names for the Section part?
1588689386
The Aaron
Roll20 Production Team
API Scripter
These are the repeating sections.  You have to find them in the sheet's HTML, usually by right clicking and inspecting.  You're looking for something like this: <fieldset class="repeating_ npcaction " style="display: none;"> It's the class name without the repeating_ part on the front.  The above would be "npcaction".
Okay... So.. For Action Longsword would be the  Section "Action" .  The value 0 or Longsword?
1588690178
The Aaron
Roll20 Production Team
API Scripter
probably it would be: --attr|npcaction|name|longsword That would look for a row in the "npcaction" repeating section with an attribute named "name" which has a value of "longsword".
It did work... for a short time. Then this happened: Your scripts are currently disabled due to an error that was detected. Please make appropriate changes to your scripts and click the "Save Script" button and we'll attempt to start running them again. More info... For reference, the error message generated was: TypeError: Cannot read property 'indexOf' of undefined TypeError: Cannot read property 'indexOf' of undefined at findObjs.find.a (apiscript.js:105:140) at Array.find (native) at getRowIdsForOps (apiscript.js:105:5) at attrOps.map (apiscript.js:215:35) at Array.map (native) at msg (apiscript.js:215:25) at eval (eval at <anonymous> (/home/node/d20-api-server/api.js:154:1), <anonymous>:65:16) at Object.publish (eval at <anonymous> (/home/node/d20-api-server/api.js:154:1), <anonymous>:70:8) at /home/node/d20-api-server/api.js:1648:12 at /home/node/d20-api-server/node_modules/firebase/lib/firebase-node.js:93:560
1588695071
The Aaron
Roll20 Production Team
API Scripter
Updated the above again to fix that issue.
@Aaron, looks great! Can this transfer queries, attribute calls, and inline rolls from the source to the destination? And if the source contains any vertical bars, can I escape them with a backslash to make sure they don't interfere with the command structure?
It works reliably now! Thank you so very much, Aaron.
1588698193

Edited 1588698260
The Aaron
Roll20 Production Team
API Scripter
This will transfer the full contexts of any attribute associated with the row, no escaping required.  You don't need to do anything special with the values of attributes, they'll be an identical copy.  If you have values with a pipe in them, you can just type them and it will retain them.  This will find the value "Slash|Pierce Magic": --attr|weapons|name|Slash|Pierce Magic matches are case insensitive and match based on substrings, so you could type for the above: --attr|weapons|name|pierce magic Additionally, it ignores spaces, so these work also: --attr|weapons|name|piercemagic --attr|weapons|name|Pierce Magic The same matching rules apply for character names. Also, you can specify as many --attr| arguments as you like, and as many --dst| arguments.
Awesome! Any chance you could add a confirmation message in chat that the transfer was successful? Like the one used by the Ammo script.
1588768549
The Aaron
Roll20 Production Team
API Scripter
Yeah, I can definitely add that.  It looks like there are a few bugs I need to address with the index stuff too...
Hello! I am obviously too stupid to use this API. It's just not working for me. A concrete example: Character Name: Weapons Action Name: Broadsword is to be copied to: character names: Morvar What is the specific command here? Best wishes
@Sebastian a lot of it depends on what sheet you are using, as you need to know the names of the repeating sections and attributes involved
1589431061

Edited 1589431487
@The Aaron, this script has been a lifesaver, and being able to use the names of items instead of having to know the ID is awesome. Would it be feasible to integrate that function into your Ammo script? I like to create buttons in my players' ranged weapon rolls and such, but that means I need to change the row IDs in the button each time I paste it to a new sheet. Maybe a command using a specific item name instead of an attribute could look like !ammo @{character_id} section|namekey|Arrow|quantitykey -1 Arrow
1589455200
The Aaron
Roll20 Production Team
API Scripter
It would be feasible. A big part of this script, the attrLookup() function, was actually taken from Ammo (or at least originated there).  I have some things I want to do to make this script better and easier to use, the slowness of the API on the weekends has just prevented me from getting any of them done...