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?

May 03 (5 years ago)

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.

May 03 (5 years ago)
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

May 04 (5 years ago)
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.

May 04 (5 years ago)
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...

May 04 (5 years ago)
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!

May 04 (5 years ago)
keithcurtis
Forum Champion
Marketplace Creator
API Scripter

Thank goodness for high level Clerics.

May 04 (5 years ago)
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

May 04 (5 years ago)
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.

May 04 (5 years ago)
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.

May 05 (5 years ago)

Edited May 05 (5 years ago)
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.
May 05 (5 years ago)
Sebastian L.
Translator

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

May 05 (5 years ago)

Edited May 05 (5 years ago)
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.

May 05 (5 years ago)

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

May 05 (5 years ago)
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.

May 05 (5 years ago)
Sebastian L.
Translator

Hello, where is a list of categories and their names for the Section part?

May 05 (5 years ago)
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".

May 05 (5 years ago)
Sebastian L.
Translator

Okay... So.. For Action Longsword would be the  Section "Action" .  The value 0 or Longsword?

May 05 (5 years ago)
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".

May 05 (5 years ago)

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


May 05 (5 years ago)
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?

May 05 (5 years ago)

It works reliably now! Thank you so very much, Aaron.

May 05 (5 years ago)

Edited May 05 (5 years ago)
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.

May 06 (5 years ago)
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...

May 13 (5 years ago)
Sebastian L.
Translator

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

May 14 (5 years ago)

Edited May 14 (5 years ago)

@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
May 14 (5 years ago)
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...