Making Work Slack More Fun: PHPBot and YakBot

I’ve been toying around with Slackbots a bit lately.  Back in January I wrote about publishing a Git log to Slack via one.  They’re dirt simple to implement and can be really useful.

They can also be fun.  I’ve added a couple bots in our office Slack just for posting randomness, running as cron jobs.  I figured I’d take a look at them here.


A couple weeks ago one of my coworkers posted a link to the documentation for a seemingly-random PHP function in our dev Slack channel.  It wasn’t random, it just pertained to an offline conversation, but we still made some cracks about how he was doing it to spread awareness of the function.

At nearly the same time, he and I both said, “That sounds like a great idea for a Slackbot.”  So I made it, and it looks like this:

<?php
$funcs = get_defined_functions();

do {
   $index = array_rand($funcs['internal']);
   $url = 'http://php.net/manual/en/function.' . str_replace('_', '-', $funcs['internal'][$index]) . '.php';
   $headers = @get_headers($url);

   if (strpos(implode(';', $headers), 'rel=shorturl') === false) {
      unset($url);
      sleep(2);
   } else {
      $attachments = [];
      $fields = [];

      $html = preg_replace("|\n[ ]*|is", '', file_get_contents($url));

      $doc = new DOMDocument();
      libxml_use_internal_errors(true);
      $doc->loadHTML($html);
      libxml_clear_errors();

      $xpath = new DOMXpath($doc);

      // "HEADER" INFORMATION
      $el = $xpath->query("//*/div[@class='refentry']")->item(0);
      $name = $xpath->query("./div[@class='refnamediv']/h1", $el)->item(0)->textContent;
      $verinfo = $xpath->query("./div[@class='refnamediv']/p[@class='verinfo']", $el)->item(0)->textContent;
      $refpurpose = $xpath->query("./div[@class='refnamediv']/p[@class='refpurpose']", $el)->item(0)->textContent;

      if (!$name) {
         sleep(2);
         continue;
      }

      // DESCRIPTION
      $el = $xpath->query("//*/div[@class='refsect1 description']")->item(0);
      $title = $xpath->query("./h3", $el)->item(0)->textContent;
      $example = $xpath->query("./div", $el)->item(0)->textContent;
      $description = $xpath->query("./p", $el)->item(0)->textContent;
      $note = $xpath->query("./blockquote", $el)->item(0)->textContent;

      $text = '';
      $text .= '```' . $example . '```' . PHP_EOL;
      $text .= $description . PHP_EOL;
      if ($note) $text .= '>>>' . $note . PHP_EOL;

      $fields[] = [
         'title' => $title,
         'value' => trim($text)
      ];

      $attachments[] = [
         'title' => $name,
         'title_link' => $url,
         'text' => $verinfo . PHP_EOL . $refpurpose,
         'fields' => $fields,
         'mrkdwn_in' => ['fields'],
      ];

      $msg = [
         'text' => '',
         'attachments' => $attachments,
         'username' => 'PHPBot',
         'icon_url' => 'https://example.com/slackbots/avatars/phpbot.png'
      ];
   }
} while (!$msg);

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, 'https://hooks.slack.com/services/TTTTTTTTTT/BBBBBBBBBB/ddddddddddddddddddddddd');
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

As you can see, it’s a little more advanced than just posting a link to a PHP.net URL.  If PHP.net had metadata on its pages that allowed for a nice-looking Slack unfurl, I might have just done that.  Instead I decided to pull content from the page we’re linking to and make the Slack post a little prettier with it.

So how does that all work?

<?php
$funcs = get_defined_functions();

do {

Simple thing here.  Get the defined PHP functions and then start a do…while loop.  By using get_defined_functions() I’m limiting what our PHPBot can link to but I figured there are enough functions available there that it doesn’t really matter.

$index = array_rand($funcs['internal']);
$url = 'http://php.net/manual/en/function.' . str_replace('_', '-', $funcs['internal'][$index]) . '.php';
$headers = @get_headers($url);

if (strpos(implode(';', $headers), 'rel=shorturl') === false) {
   unset($url);
   sleep(2);
} else {

We jump into our do…while loop, where we grab a random function from our array of functions and build what should be a PHP.net documentation URL from the name.  For some reason, not every function has documentation (or at least not that matches this template), which is why we’re in a do…while loop.

We get the headers for that URL and if no shorturl is defined there, we know we don’t have a valid function documentation page.  In that case, we sleep (I like to sleep in situations like this) and unset the URL, then we’ll take another run at the do…while loop.

If we do have a shorturl, we get into the heavy lifting.

$attachments = [];
$fields = [];

$html = preg_replace("|\n[ ]*|is", '', file_get_contents($url));

$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXpath($doc);

We initialize a couple arrays that we’ll use for building our Slack message, then we pull in the HTML from the documentation page and strip out line breaks and any spaces that might start a line.  We do that stripping so that the text we use in our message later on looks better.

Then we load that HTML into a DOMDocument and fire up XPath for it so we can target the elements we want a little easier.

// "HEADER" INFORMATION
$el = $xpath->query("//*/div[@class='refentry']")->item(0);
$name = $xpath->query("./div[@class='refnamediv']/h1", $el)->item(0)->textContent;
$verinfo = $xpath->query("./div[@class='refnamediv']/p[@class='verinfo']", $el)->item(0)->textContent;
$refpurpose = $xpath->query("./div[@class='refnamediv']/p[@class='refpurpose']", $el)->item(0)->textContent;

if (!$name) {
   sleep(2);
   continue;
}

We grab the element of the page that contains all of the information about the function by targeting the refentry class.

Inside that element is a div with class refnamediv, which contains an H1 with the function name, a paragraph classed as verinfo with information about what versions of PHP support the function, and a paragraph defining the function classed as refpurpose.  We grab each of these as we’ll use them in our message.

If – for some reason – we didn’t get a function name, we sleep (like I said, I like to sleep in these situations) and then head back to the start of our do…while loop.

// DESCRIPTION
$el = $xpath->query("//*/div[@class='refsect1 description']")->item(0);
$title = $xpath->query("./h3", $el)->item(0)->textContent;
$example = $xpath->query("./div", $el)->item(0)->textContent;
$description = $xpath->query("./p", $el)->item(0)->textContent;
$note = $xpath->query("./blockquote", $el)->item(0)->textContent;

$text = '';
$text .= '```' . $example . '```' . PHP_EOL;
$text .= $description . PHP_EOL;
if ($note) $text .= '>>>' . $note . PHP_EOL;

Having advanced this far, we target the “Description” section of the function documentation page, which has a class of refsect1 description.

Inside that div is an H3 that serves as the section title, a div that contains an example use of the function, a paragraph with a description, and an optional blockquote with notes about the function.  We target each of these and pull that content in, then we use them to build the test of our message.

It should be noted that we use markup to make sure that the example is displayed as a code block while the note(s) (if present) are displayed as a quote, mimicking their appearance on PHP.net.

       $fields[] = [
         'title' => $title,
         'value' => trim($text)
      ];

      $attachments[] = [
         'title' => $name,
         'title_link' => $url,
         'text' => $verinfo . PHP_EOL . $refpurpose,
         'fields' => $fields,
         'mrkdwn_in' => ['fields'],
      ];

      $msg = [
         'text' => '',
         'attachments' => $attachments,
         'username' => 'PHPBot',
         'icon_url' => 'https://example.com/slackbots/avatars/phpbot.png'
      ];
   }
} while (!$msg);

With all of the information we need acquired, we build our message.

The description section title and our formatted text from it are added as a Slack attachment field.  That attachment gets the function name as a title (linked to the documentation URL) and it’s text is the version and purpose text we grabbed early on.

We post this all as user “PHPBot” with a hosted avatar.  These two steps aren’t necessary as you can define your incoming webhook’s name and avatar, but we’re reusing a webhook for multiple purposes and, as such, define these for each.

Then we hit the end of our do…while loop.  We’ve assembled our message so we can move on.

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, 'https://hooks.slack.com/services/TTTTTTTTTT/BBBBBBBBBB/ddddddddddddddddddddddd');
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

Lastly, we actually send that message.  It’s just a cURL post to the Slack webhook URL (obscured here) and ends up posting a message that looks like this:

 

PHPBot example

As I said, there’s nothing too complex here.  If anything, this is probably over-complicated.  But it gives us something to talk about at 2:05 every day.


Last week another coworker posted to our developer channel asking that we all send a random photo of a Yak to one of our non-dev coworkers.  My immediate thought was that this was begging to be made into a Slackbot, and thus the YakBot was created.

<?php
$api_key = 'AbCdEfGhIjKlMnOpQrS1TuVw2xYzAbC3DeFgHiJk';
$search_engine_id = '012345678901234567890:abc0defghijk';

$channels = [
   '#developer-channel',
   'U197UV7HV',
   'U1989NZ12',
   'U198JH8GL',
   'U99EYR89S',
   'U198L05JG',
   'U198GH6H7',
   'U74JZ1KAY',
   'UAPFKFAHG',
   'U4U7VSABA',
];

$index = rand(1, 91);

$url = 'https://www.googleapis.com/customsearch/v1?q=yak&cx=' . $search_engine_id . '&imgSize=medium&safe=high&searchType=image&key=' . $api_key . '&start=' . $index;
$json = file_get_contents($url);
$data = json_decode($json);

$image_url = $data->items[0]->link;

$msg = [
   'text' => '',
   'attachments' => [
      [
         'image_url' => $image_url
      ]
   ],
   'username' => 'YakBot',
   'icon_url' => 'https://example.com/slackbots/avatars/yakbot.png'
];

$recipients = [];
foreach ($channels AS $channel) {
   $rand = rand(0, 9);
   if (!$rand) {
      $recipients[] = $channel;
   }
}

foreach ($recipients AS $recipient) {
   $msg['channel'] = $recipient;

   // send message
   $c = curl_init();
   curl_setopt($c, CURLOPT_URL, 'https://hooks.slack.com/services/TTTTTTTTTT/BBBBBBBBBB/ddddddddddddddddddddddd');
   curl_setopt($c, CURLOPT_POST, 1);
   curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
   curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
   curl_setopt($c, CURLOPT_HEADER, 0);
   curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
   curl_exec($c);
   curl_close($c);
}

We bring in the Google search API for this one in addition to the Slack API but overall it’s a bit simpler because there’s less of a message to build.

<?php
$api_key = 'AbCdEfGhIjKlMnOpQrS1TuVw2xYzAbC3DeFgHiJk';
$search_engine_id = '012345678901234567890:abc0defghijk';

$channels = [
   '#developer-channel',
   'U197UV7HV',
   'U1989NZ12',
   'U198JH8GL',
   'U99EYR89S',
   'U198L05JG',
   'U198GH6H7',
   'U74JZ1KAY',
   'UAPFKFAHG',
   'U4U7VSABA',
];

$index = rand(1, 91);

The first thing we do is define our Google search API key and the ID of the custom search engine that we’re using.  Then we build an array of possible recipients of our yak.  The recipients include our developer channel as well as individual user IDs, to whom the message would be sent in the form of a direct message from Slackbot.

Next we select a random number between 1 and 91.  This is because the Google search API won’t let you request a start point higher than 91 for some reason.  The search results return ten items, which means the most results you can get is 100.  We only need one and one 1/91 is close enough to 1/100 so I do the randomizing up front and then do the lookup and take the first response.

$url = 'https://www.googleapis.com/customsearch/v1?q=yak&cx=' . $search_engine_id . '&imgSize=medium&safe=high&searchType=image&key=' . $api_key . '&start=' . $index;
$json = file_get_contents($url);
$data = json_decode($json);

$image_url = $data->items[0]->link;

Here we actually do that lookup.  We get JSON back and decode it to pull out the first item listed.

$msg = [
   'text' => '',
   'attachments' => [
      [
         'image_url' => $image_url
      ]
   ],
   'username' => 'YakBot',
   'icon_url' => 'https://example.com/slackbots/avatars/yakbot.png'
];

Once we have our image URL, we build our message, which is relatively simple compared to the PHPBot message because all we’re doing is posting an attachment with an image.  As I mentioned above, we also define a custom username and avatar, though we don’t need to.

$recipients = [];
foreach ($channels AS $channel) {
   $rand = rand(0, 9);
   if (!$rand) {
      $recipients[] = $channel;
   }
}

foreach ($recipients AS $recipient) {
   $msg['channel'] = $recipient;

   // send message
   $c = curl_init();
   curl_setopt($c, CURLOPT_URL, 'https://hooks.slack.com/services/TTTTTTTTTT/BBBBBBBBBB/ddddddddddddddddddddddd');
   curl_setopt($c, CURLOPT_POST, 1);
   curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
   curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
   curl_setopt($c, CURLOPT_HEADER, 0);
   curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
   curl_exec($c);
   curl_close($c);
}

Finally, we wrap it up by determining who to send our yak to.  We loop through each of our channels and generate a random number between zero and nine.  If the number is zero, we add that channel to the list of recipients.

We do that because it allows for some randomness.  It’d get noisy if we were posting yaks too often or too regularly.

Then we loop through the list of recipients and send the message, defining the channel along the way.  The lucky winners end up getting something like this:

YakBot example

Posting to Slack via Git Hooks

At my day job our codebase is kept in a handful of self-hosted Git repositories. We have a tool that runs nightly, emailing out a digest of all of the previous day’s commits.

It’s kinda cool but I have the tendency to ignore it as a wall of text. I prefer more granular messaging and since we’re also using Slack, I saw an opportunity to do something with a post-receive hook to get a message as changes came in.

Posting from Git to Slack isn’t revolutionary. There are a tons of solutions for this out there. In fact, my original attempt just used a modified version of Chris Eldredge’s shell script, which I grabbed off of GitHub.  However, my Bash-foo is weak and we’re a PHP shop so I decided to write a solution based in PHP (though heavily based on Eldredge as I had that code in front of me).

To fire off the PHP script, the post-receive hook looks like this:

#!/bin/bash
while read oldrev newrev refname
	php git-slack-post.php --oldrev="$oldrev" --newrev="$newrev" --refname="$refname"
done

That’s simplified a bit as the actual hooks use an absolute path to the script but you see that the script accepts the oldrev, newrev, and refname arguments.

As for the PHP script itself, it looks a bit like this (I’ve sanitized some things to remove references to our internal services).

#!/usr/bin/php -f
<?php
// expects to be called as follows:
// php git-slack-post.php --oldrev="1234567890abcdef" --newrev="0987654321fedcba" --refname="refs/foo/bar"

const WEBHOOK_URL = 'https://hooks.slack.com/services/ABC123FDG/456HIJ789/tWeNtYfOuRcHaRaCtErS0024';
const ZEROREV = '0000000000000000000000000000000000000000';

// pull the arguments from the command line
$newrev = $oldrev = $refname = '';
extract(getopt('', array('oldrev:', 'newrev:', 'refname:')));

// determine change type based on what revisions are available
if ($oldrev === ZEROREV) {
	$change_type = 'create';
} elseif ($newrev === ZEROREV) {
	$change_type = 'delete';
} else {
	$change_type = 'update';
}

$newrev_type = trim(`git cat-file -t {$newrev}`);
$oldrev_type = trim(`git cat-file -t {$oldrev}`);

$rev = $rev_type = '';
if (in_array($change_type, array('create', 'update'))) {
	$rev = $oldrev;
	$rev_type = $oldrev_type;
} elseif ($change_type === 'delete') {
	$rev = $newrev;
	$rev_type = $newrev_type;
}

if ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'tag')) {
	$refname_type = 'annotated tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/heads/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'branch';
	$short_refname = str_replace('refs/heads/', '', $refname);
} elseif ((strpos($refname, 'refs/remotes/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tracking branch';
	$short_refname = str_replace('refs/remotes/', '', $refname);

	echo '*** Push-update of tracking branch, ' . $refname;
	echo '***  - no notification generated.';

	exit;
} else {
	// this shouldn't be possible
	echo '*** Unknown type of update to ' . $refname . ' (' . $rev_type . ')';
	echo '***  - no notification generated.';

	exit;
}

// get the repo name
// only get the final directory name, cut ".git" off the end
$repo = substr(basename(realpath(getcwd())), 0, -4);

// get the user
$process_user = posix_getpwuid(posix_geteuid());
$user = $process_user['name'];

$header = '[' . $repo . '/' . $short_refname . '] ';
switch ($change_type) {
	case 'create':
		$header .= 'New ' . $refname_type . ' has been created';
		break;
	case 'delete':
		$header .= ucwords($refname_type) . ' has been deleted';
		break;
	case 'update':
		$commits = intval(trim(`git log --pretty=oneline {$oldrev}..{$newrev}|wc -l`));
		$header .= $commits . ' new commit' . (($commits > 1) ? 's' : '') . ' pushed by ' . $user;
		break;
	default:
		// this shouldn't be possible
		echo '*** Unknown type of update to ' . $refname . '(' . $rev_type . ')';
		echo '***  - notifications will probably screw up.';
		break;
}

$start = ($change_type === 'update') ? $oldrev : 'HEAD';
$end = $newrev;

// get git data
$data = `git log --pretty=format:"%an&&&&&%h&&&&&%s&&&&&%b@@@@@" {$start}..{$end}`;

$attachments = array();
foreach (explode('@@@@@', $data) AS $item) {
	if (!trim($item)) {
		continue;
	}
	list($author, $hash, $comment, $body) = explode('&&&&&', $item);

	$text = trim('`' . $hash . '` ' . $comment . ' - *' . trim($author) . '*' . "\n\n" . $body);
	$fallback = trim($hash . ' - ' . $comment . ' - ' . trim($author) . "\n\n" . $body);

	// link jira tasks
	$text = preg_replace('#ABC-(\d+)#is', '<https://jira.example.com/browse/ABC-$1|ABC-$1>', $text);

	$text = preg_replace('#\[ABC\][ ]?\((\d+)\)#is', '<https://jira.example.com/browse/ABC-$1|$0>', $text);

	// link support bugs/tickets
	if (preg_match('#(TICKET|BUG|SUPPORT)[\-| ](\d+)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.php?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	if (preg_match('#\[HOTFIX\][ ]?\((\d+)\)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.mh?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	$attachments[] = array(
		'text'      => $text,
		'fallback'  => $fallback,
		'color'     => '#1881d0',
		'mrkdwn_in' => array('text'),
	);
}

// build message
$msg = array('text' => $header, 'attachments' => $attachments);

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, WEBHOOK_URL);
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

We get details about what’s being pushed and build a message out of all of that. Simple enough. So lets break that down a little bit.

const WEBHOOK_URL = 'https://hooks.slack.com/services/ABC123FDG/456HIJ789/tWeNtYfOuRcHaRaCtErS0024';
const ZEROREV = '0000000000000000000000000000000000000000';

// pull the arguments from the command line
$newrev = $oldrev = $refname = '';
extract(getopt('', array('oldrev:', 'newrev:', 'refname:')));

We start by defining our Slack webhook URL (which can be set up at https://my.slack.com/services/new/incoming-webhook) and what an “empty” revision number looks like. Then we pull in the oldrev, newrev, and refname that should have been passed into the script as arguments.

// determine change type based on what revisions are available
if ($oldrev === ZEROREV) {
	$change_type = 'create';
} elseif ($newrev === ZEROREV) {
	$change_type = 'delete';
} else {
	$change_type = 'update';
}

$newrev_type = trim(`git cat-file -t {$newrev}`);
$oldrev_type = trim(`git cat-file -t {$oldrev}`);

If the old revision is empty, it means we’re creating something. If the new revision is empty, it means we’re deleting something. Otherwise it’s an update of something that already existed and continues to do so.

Whatever the change type, we get more information about the old and new revisions by using the backtick operator to run the get cat-file -t command for each revision number.

$rev = $rev_type = '';
if (in_array($change_type, array('create', 'update'))) {
	$rev = $oldrev;
	$rev_type = $oldrev_type;
} elseif ($change_type === 'delete') {
	$rev = $newrev;
	$rev_type = $newrev_type;
}

If the change type is a create or an update, we’ll use the old revision data to reference things going forward. If it’s a delete we’ll use the new revision data.

if ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/tags/') === 0) AND ($rev_type === 'tag')) {
	$refname_type = 'annotated tag';
	$short_refname = str_replace('refs/tags/', '', $refname);
} elseif ((strpos($refname, 'refs/heads/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'branch';
	$short_refname = str_replace('refs/heads/', '', $refname);
} elseif ((strpos($refname, 'refs/remotes/') === 0) AND ($rev_type === 'commit')) {
	$refname_type = 'tracking branch';
	$short_refname = str_replace('refs/remotes/', '', $refname);

	echo '*** Push-update of tracking branch, ' . $refname;
	echo '***  - no notification generated.';

	exit;
} else {
	// this shouldn't be possible
	echo '*** Unknown type of update to ' . $refname . ' (' . $rev_type . ')';
	echo '***  - no notification generated.';

	exit;
}

This is just a bunch of logic that looks at the refname and the revision type and determines exactly what you’ve pushed. If we can’t figure out what it is, we exit with an error.

// get the repo name
// only get the final directory name, cut ".git" off the end
$repo = substr(basename(realpath(getcwd())), 0, -4);

// get the user
$process_user = posix_getpwuid(posix_geteuid());
$user = $process_user['name'];

We determine the repo name based on the path the hook is running from and we get the user it’s running as so we know who did the push we’re about to notify people of.

$header = '[' . $repo . '/' . $short_refname . '] ';
switch ($change_type) {
	case 'create':
		$header .= 'New ' . $refname_type . ' has been created';
		break;
	case 'delete':
		$header .= ucwords($refname_type) . ' has been deleted';
		break;
	case 'update':
		$commits = intval(trim(`git log --pretty=oneline {$oldrev}..{$newrev}|wc -l`));
		$header .= $commits . ' new commit' . (($commits > 1) ? 's' : '') . ' pushed by ' . $user;
		break;
	default:
		// this shouldn't be possible
		echo '*** Unknown type of update to ' . $refname . '(' . $rev_type . ')';
		echo '***  - notifications will probably screw up.';
		break;
}

Now we start building the message that will be posted to Slack. The message begins in the form of “[reponame/branchname]. If it’s a create or delete, we then note what was created or deleted. If it’s a commit, we note the number of commits and who they were pushed by.

$start = ($change_type === 'update') ? $oldrev : 'HEAD';
$end = $newrev;

// get git data
$data = `git log --pretty=format:"%an&&&&&%h&&&&&%s&&&&&%b@@@@@" {$start}..{$end}`;

$attachments = array();
foreach (explode('@@@@@', $data) AS $item) {
	if (!trim($item)) {
		continue;
	}
	list($author, $hash, $comment, $body) = explode('&&&&&', $item);

We’re going to start building a series of messages (what Slack calls “attachments”) detailing items from the Git log pertinent to this push. We use the git log command and define our format. We get the author name with %an, the hash with %h, the commit message with %s and the commit body with %b. Those are all separated by five ampersands, with each item separated by five at signs. We use those goofy separators so we can split on them later, as it’s unlikely anyone enters those in text.

	$text = trim('`' . $hash . '` ' . $comment . ' - *' . trim($author) . '*' . "\n\n" . $body);
	$fallback = trim($hash . ' - ' . $comment . ' - ' . trim($author) . "\n\n" . $body);

Here we actually build our message. The text property of a Slack attachment can be markdown, so we pretty it up a little bit. The fallback property is plaintext so it doesn’t get that formatting. The result is the hash, then the commit message, then the author. If there is a longer commit message, it gets added after that.

	// link jira tasks
	$text = preg_replace('#ABC-(\d+)#is', '<https://jira.example.com/browse/ABC-$1|ABC-$1>', $text);

	$text = preg_replace('#\[ABC\][ ]?\((\d+)\)#is', '<https://jira.example.com/browse/ABC-$1|$0>', $text);

	// link support bugs/tickets
	if (preg_match('#(TICKET|BUG|SUPPORT)[\-| ](\d+)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.php?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

	if (preg_match('#\[HOTFIX\][ ]?\((\d+)\)#is', $text, $match)) {
		$replace = '<https://support.example.com/passthru.mh?item=' . $match[0] . '|' . $match[0] . '>';
		$text = str_replace($match[0], $replace, $text);
	}

We’re not done with that text yet, though. We have a loosely-followed naming convention for our commits and we can use that to link back to other systems that might have more information about the commit. Anything that was a Jira task should start with the task number in the format “[ABC-1234]” but sometimes it’s “(ABC-1234)” or “[ABC] (1234)” or “[ABC](1234)” so we account for all of those. Similarly, references to our ticket system sometimes use “TICKET” or “BUG” or “SUPPORT” and sometimes have a space or a dash and sometimes use “HOTFIX” and… You get the idea. There are probably better regular expressions to use here but these work. So we find references to our Jira cards and link back to them, then find references to our support system and link back to it, where there’s a script that will do some additional parsing to figure out where to go.

	$attachments[] = array(
		'text'      => $text,
		'fallback'  => $fallback,
		'color'     => '#1881d0',
		'mrkdwn_in' => array('text'),
	);

With all of that done, we build an array for this attachment, defining our text, our fallback text, a color to display alongside the attachment, and confirming that there is markdown in our text field.

}

// build message
$msg = array('text' => $header, 'attachments' => $attachments);

// send message
$c = curl_init();
curl_setopt($c, CURLOPT_URL, WEBHOOK_URL);
curl_setopt($c, CURLOPT_POST, 1);
curl_setopt($c, CURLOPT_POSTFIELDS, json_encode($msg));
curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($c, CURLOPT_HEADER, 0);
curl_setopt($c, CURLOPT_HTTPHEADER, array('Content-type: application/json'));
curl_exec($c);
curl_close($c);

Once we’re done looping through our data from the git log and building our attachments, we put together the message and send it off via Curl. The message is just an array, which then gets JSON-encoded and posted to our webhook URL.

It’s fire-and-forget, so we don’t make any note if the webhook doesn’t respond or anything like that.

For a short time we had an additional message attachment that included diff data but we decided we didn’t want our code getting posted to Slack so we removed it.

As I said, there are tons of solutions for this out there, this is just one more.

Trello as an Interface via Webhooks

So I’m a bit particular about how I keep my finances in order.  To the point that I wrote my own web-based ledger software to help myself keep it all straight.  Yeah, there are third-party solutions out there, but I wanted something that worked exactly the way I wanted.

Every couple days I pull receipts out of my pocket and go through my email inbox and drop new entries in my ledger.  Every couple weeks I manually reconcile the ledger with my banking statements.  Manual processes – ew – but it’s important enough to me that I do it.

The issue I ran into was transactions that I didn’t have a receipt for.  Tim Horton’s drive-thru or gas station pumps with a broken receipt printer or the Flint Firebirds’ souvenir shop using SquareCash.  No receipt means no ledger entry means confusion when I go to reconcile.

I could have made a mobile version of the ledger entry form but I really didn’t want to. As such I decided I could fix the issue by keeping faux-receipts electronically in Trello.  A single list.  Card title is the place I spent the money, description is the amount, a label to represent the account.  Once there’s a ledger entry, archive the card.

And that would have been enough.  I decided to take it a step further and automate things.

I use a webhook subscribed to that single list to look for new cards, get the data from them, automatically add the record to my ledger, then archive the card. I’m essentially using Trello’s app as an interface for my own so that I don’t have to make a mobile interface.

It’s a bit hacky but I figured I’d throw some of the code out here since I feel like there’s not a lot of Trello webhook documentation out there.  Unlike my usual, I’m going to redact some of the code as it deals with my financial system and I’d prefer not to put that out there.

<?php
  require_once 'trello_api.php';
  $trello = new trello_api($trello_config['key'], $trello_config['secret'], $trello_config['token']);

  $data = json_decode(file_get_contents('php://input'));

  if (($data->action->type == 'createCard') AND ($data->model->id == $data->action->data->list->id)) {
    // CREATE WEBHOOK ON CARD
    $webhook = $trello->request('POST', '/1/webhook', array('description' => ('Temporary callback for Card ' . $data->action->data->card->id), 'callbackURL' => 'http://www.example.com/webhook_callback.php', 'idModel' => $data->action->data->card->id));
    $trello->request('POST', ('/1/card/' . $data->action->data->card->id . '/actions/comments'), array('text' => ('Webhook ID: ' . $webhook->id)));
  }

  if (($data->model->id == $data->action->data->card->id) AND (($data->action->type == 'createCard') OR ($data->action->type == 'updateCard') OR ($data->action->type == 'addLabelToCard'))) {
    // ASSIGN VARIABLES
    $date = date('Y-m-d', strtotime($data->action->date));
    $label = trim($data->model->name);
    $amt = trim(str_replace('$', '', $data->model->desc));

    $category = get_category_id($label);
    $account_id = get_account_name($data->model->labels[0]->name);

    $details = array(array('category_id' => $category, 'amt' => $amt));

    if (($account_id) AND ($category) AND (is_numeric($amt))) {
      // DATA LOOKS GOOD, REMOVE WEBHOOK, ARCHIVE CARD, AND CREATE ENTRY
      $comments = $trello->request('GET', ('/1/cards/' . $data->model->id . '/actions'), array('filter' => 'commentCard', 'fields' => 'data'));

      $webhook_id = 0;
      foreach ($comments AS $comment) {
        if (strstr($comment->data->text, 'Webhook ID: ')) {
          $webhook_id = str_replace('Webhook ID: ', '', $comment->data->text);
          break;
        }
      }

      if ($webhook_id) {
        // FOUND A WEBHOOK, EVERYTHING WORKING AS IT SHOULD
        $trello->request('DELETE', ('/1/webhook/' . $webhook_id));
        $trello->request('PUT', ('/1/card/' . $data->model->id . '/closed'), array('value' => true));
        create_ledger_entry($account_id, $date, $label, $amt, $details);
      } else {
        // DIDN'T FIND A WEBHOOK, STUFF IS BROKEN
        $trello->request('POST', ('/1/card/' . $data->model->id . '/actions/comments'), array('text' => 'No webhook found.'));
      }
    } else {
      // DATA LOOKS NOT GOOD, NOTE IN CARD
      $msg = '';
      if (!$account_id) $msg .= 'Invalid account selected' . "\n\n";
      if (!is_numeric($amt)) $msg .= 'Invalid amount selected: ' . $amt . "\n\n";
      if (!$category) $msg .= 'No category found' . "\n\n";

      if ($msg) $trello->request('POST', ('/1/card/' . $data->model->id . '/actions/comments'), array('text' => trim($msg)));
    }
  }

  unset($trello);
?>

As I said, I’ve replaced some of the actual code with obscured-away psudo-functions. I’ll point those out those spots as necessary while going through this piece-by-piece.

require_once 'trello_api.php';
$trello = new trello_api($trello_config['key'], $trello_config['secret'], $trello_config['token']);

$data = json_decode(file_get_contents('php://input'));

Right off the bat we pull in my Trello API wrapper class (which really could use some love, maybe I’ll get to that sooner or later) and instantiate an object that we’ll use later. Then we pull in the data Trello posted to us via the input stream.

if (($data->action->type == 'createCard') AND ($data->model->id == $data->action->data->list->id)) {
  // CREATE WEBHOOK ON CARD
  $webhook = $trello->request('POST', '/1/webhook', array('description' => ('Temporary callback for Card ' . $data->action->data->card->id), 'callbackURL' => 'http://www.example.com/webhook_callback.php', 'idModel' => $data->action->data->card->id));
  $trello->request('POST', ('/1/card/' . $data->action->data->card->id . '/actions/comments'), array('text' => ('Webhook ID: ' . $webhook->id)));
}

Webhooks subscribed to a list don’t give us all the details of cards on the list, so if we detect a card creation and we’re certain we’re getting data from the webhook attached to the list, we add a webhook to the new card. This is done with a POST to /1/webhook passing in a description (which is optional and doesn’t really matter), callbackURL (same as the URL of this script, though obscured here), and idModel (the ID of the card). For future reference, we then post the ID of the newly-created webhook to a comment on the card via POST to /1/card/<card_id>/actions/comments with text set as needed.

if (($data->model->id == $data->action->data->card->id) AND (($data->action->type == 'createCard') OR ($data->action->type == 'updateCard') OR ($data->action->type == 'addLabelToCard'))) {
  // ASSIGN VARIABLES
  $date = date('Y-m-d', strtotime($data->action->date));
  $label = trim($data->model->name);
  $amt = trim(str_replace('$', '', $data->model->desc));

  $category = get_category_id($label);
  $account_id = get_account_name($data->model->labels[0]->name);

  $details = array(array('category_id' => $category, 'amt' => $amt));

The rest of the code only fires if we’re receiving from the new webhook attached to the card, and if it’s triggered by either a card creation (which should never happen since the webhook won’t have been created yet), card update (which happens when I set the card’s description), or label addition (which is how I define what account the transaction is for). We only care about those actions because they match the ones I take on the front-end.

From there, we use attributes of the card to build the transaction. The transaction date is whatever the action is taking place. The label is the name of the card. The amount comes from the card description. The category is determined based on the label. The account_id is determined based on the first label selected. A single-item, multidimensional array (the actual interface handles more complex data) is assembled from this data.

if (($account_id) AND ($category) AND (is_numeric($amt))) {
  // DATA LOOKS GOOD, REMOVE WEBHOOK, ARCHIVE CARD, AND CREATE ENTRY
  $comments = $trello->request('GET', ('/1/cards/' . $data->model->id . '/actions'), array('filter' => 'commentCard', 'fields' => 'data'));

  $webhook_id = 0;
  foreach ($comments AS $comment) {
    if (strstr($comment->data->text, 'Webhook ID: ')) {
      $webhook_id = str_replace('Webhook ID: ', '', $comment->data->text);
      break;
    }
  }

If we got an account ID and a category and the amount is numeric, we know we got good data and we’re ready to try to add the record. First, though, we make a GET call to /1/cards/<card_id>/actions with filter set to commentCard and fields set to data, so that we can get all of the comments posted to the card. We loop through them until we find the one where we stored the card webhook ID and then we save that ID off. There’s probably a better way to do this.

if ($webhook_id) {
  // FOUND A WEBHOOK, EVERYTHING WORKING AS IT SHOULD
  $trello->request('DELETE', ('/1/webhook/' . $webhook_id));
  $trello->request('PUT', ('/1/card/' . $data->model->id . '/closed'), array('value' => true));
  create_ledger_entry($account_id, $date, $label, $amt, $details);

If we found a webhook ID (and we always should), we delete the webhook, archive the card, and create the ledger entry. The first is accomplished with a DELETE request to /1/webhook/<webhook_id>. The second is a PUT to /1/card/<card_id>/closed with value set to true.

} else {
  // DIDN'T FIND A WEBHOOK, STUFF IS BROKEN
  $trello->request('POST', ('/1/card/' . $data->model->id . '/actions/comments'), array('text' => 'No webhook found.'));
}

If we didn’t get a webhook ID, something is seriously broken so we log that finding to the card as a comment via a POST to /1/card/<card_id>/actions/comments with text set to “No webhook found.”

    } else {
      // DATA LOOKS NOT GOOD, NOTE IN CARD
      $msg = '';
      if (!$account_id) $msg .= 'Invalid account selected' . "\n\n";
      if (!is_numeric($amt)) $msg .= 'Invalid amount selected: ' . $amt . "\n\n";
      if (!$category) $msg .= 'No category found' . "\n\n";

      if ($msg) $trello->request('POST', ('/1/card/' . $data->model->id . '/actions/comments'), array('text' => trim($msg)));
    }
  }

  unset($trello);
?>

Lastly, if we don’t have all of the things we need to make a ledger entry we log that to the card as a comment. Again, that’s done with a POST to /1/card/<card_id>/actions/comments.

As I said, it’s hacky. It gave me a chance to play with Trello webhooks a bit, though, and was a lot of fun. As I use it more, we’ll see what I did wrong.

Exporting from Snagit to Amazon S3: Revisited

One of the first things I wrote about when I started this blog was my workaround solution for exporting from TechSmith Snagit to Amazon S3.  That worked okay for Windows but I’ve started working on Mac significantly more of late and I missed that functionality.  As such, I took another look at options for this since TechSmith itself still hasn’t developed a Snagit to S3 output for either Windows or Mac.

I feel like exporting on Mac shouldn’t be a problem.  There’s no S3 Browser available but you could replace it with s3cmd and do the same thing.  The catch: There’s no Program Output option in Snagit Mac.  That’s right, on Windows you can essentially make your own outputs but on Mac you’re out of luck.

I came up with a workaround, though.  It’s not pretty but it works.  It also works on Windows, but with better options available I’m not sure there’s a reason to use it.

I use ExpanDrive to map my S3 buckets as a local drive.  Then I can save from Snagit straight to the location I want in S3.  That part’s great.  It’s pretty much seamless.  ExpanDrive is a really awesome tool.  Probably too expensive if all you’re using it for is Snagit exporting, but worth taking a look at if you’re working with S3 in other ways.

The problem is you don’t get the uploaded URL out of this.  That’s where it gets hacky.

I wrote a Chrome extension that gets me a list of the last five files uploaded to this particular S3 bucket.  So after saving my file, I have to go to my browser to get its URL.  Extra steps.  The bonus is that I can get the URL any time later.

A view of my Chrome extension, showing the last five files uploaded to my filebox S3 bucket.
A view of my Chrome extension, showing the last five files uploaded to my filebox S3 bucket.

Since the ExpanDrive part of it works out of the box, here’s the breakdown of my Chrome extension.

<?php
  // GET THE DATA
  $s3 = Aws\S3\S3Client::factory(array('key' => $access_key, 'secret' => $secret_key, 'region' => $region));

  $objects = array();

  do {
    $response = $s3->listObjects(array('Bucket' => $bucket_name, 'Marker' => $response->NextMarker));

    foreach ($response['Contents'] AS $item) {
      $objects[] = array('key' => $item['Key'], 'url' => ($bucket_url . $item['Key']), 'timestamp' => strtotime($item['LastModified']));
    }
  } while ($response->NextMarker);

  unset($s3);

  // SORT THE DATA
  usort($objects, function($a, $b) {
    return $a['timestamp'] - $b['timestamp'];
  });

  $display = array();
  while (count($display) < 5) {
    $display[] = array_pop($objects);
  }

  // OUTPUT
  header ('Content-type: application/json');
  echo json_encode($display);
?>

I start with a script on the server side that uses the AWSSDKforPHP2 to read in the files from my filebox, sort by date, and grab the five most recent.  Those five are then spit out as JSON.

To access that file from the Chrome extension, it’s important to include that domain in the permissions section of the manifest.json file.  Also necessary is the clipboardWrite permission.  In addition to the required manifest file, the extension uses a single HTML page, a stylesheet (which I’ll skip here since how it looks doesn’t really matter), and a Javascript file.  There are also some images but I’ll skip those, too.

<!doctype html>
<html>
  <head>
    <title>ClarkRasmussen.com Online Filebox</title>
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div id="container">
      <div id="branding_icon">
        <img src="img/icon-48.png" alt="icon" />
      </div>
      <div id="main">
        <h1>ClarkRasmussen.com Online Filebox</h1>

        <ul id="content"></ul>
      </div>
    </div>

    <script src="main.js"></script>
  </body>
</html>

The important things here are the UL element with the ID of content and the inclusion of main.js.  The UL will be targeted by our JS for dynamically adding elements.

var site_url = 'http://www.mydomain.com/';

function request_data () {
  var xhr = new XMLHttpRequest();
  xhr.open('get', (site_url + 'path/to/script.php'), true);
  xhr.onload = populate_list;
  xhr.send();
}

function populate_list () {
  var obj = JSON.parse(this.responseText);

  // CLEAR OUT EXISTING ELEMENTS
  var container = document.getElementById('content');
  while (container.firstChild) {
    container.removeChild(container.firstChild);
  }

  // ADD NEW ELEMENTS
  for (var n = 0; n < obj.length; n++) {
    var li = document.createElement('li');
    var a = document.createElement('a');

    a.innerHTML = obj[n].key;
    a.setAttribute('href', obj[n].url);
    a.setAttribute('target', '_blank');
    a.onclick = function () {
      copy_to_clipboard(this.getAttribute('href'));
    };

    li.appendChild(a);
    container.appendChild(li);
  }
}

function copy_to_clipboard (text) {
  const input = document.createElement('input');
  input.style.position = 'fixed';
  input.style.opacity = 0;
  input.value = text;
  document.body.appendChild(input);
  input.select();
  document.execCommand('Copy');
  document.body.removeChild(input);
};

window.addEventListener('load', request_data);

The request_data function wraps a call to the PHP script noted above.  Onload of that data, we call populate_list.

The first thing we do in populate_list is parse the text we got from the PHP script into an actual JSON object.  Then we remove any list items we may have in our previously-mentioned UL.  We loop through each of the items in our JSON object and create new elements for them.  Each item gets an LI with an A inside it.  The A has an HREF of the item’s URL and the TARGET is set to _blank so it opens in a new window.  Additionally, we use the copy_to_clipboard method that I grabbed from someone’s GitHub to save that URL to the clipboard, setting it as an onclick event for the A tag.

I’m certain that this could be cleaned up and made more configurable and turned into a publicly-available extension but I’m not going to bother with it.  I figured I’d put this out and hope that it helps someone.

I will say that one idea I’m intrigued by is replacing the PHP script with an AWS Lambda function that triggers any time the S3 bucket is updated.  I’m not entirely certain how that would work but it seems possible.

Retro Code Sample: TSC Interact! Hangout Video Controller

I Tweeted on Friday about futzing with Google Hangouts, something I hadn’t had to deal with in years.  The link I sent out went to a blog post about lessons learned from that experience but I realized that I never bothered to actually write up the code I wrote in that project and figured it might help some people.

The issue I was trying to solve was that I was working on a team that was trying to incorporate a remote employee and a handful of people that sometimes worked from home.  In the office, the team was split across two spaces that were next to each other.  Each space was given a TV, webcam, and microphone.  A problem immediately became apparent when the two local stations started causing feedback between each other.  Someone solved this by turning off the speakers and microphone on one of the stations, which left that room unable to easily communicate with people in the hangout.

To solve this, I took advantage of the fact that the two stations were logged in as the same user and wrote a Hangouts app to run on those machines.  The app looks for another person logged in as the same user and mutes them, eliminating feedback.  It also blocks that station from appearing on video if another user is available to be seen.

As per my usual, I’ll start with the big block of code, then break it down in small chunks.

<?xml version="1.0" encoding="UTF-8" ?>
<Module>
  <ModulePrefs title="TSC Interact! Hangout Video Controller">
    <Require feature="rpc"/>
  </ModulePrefs>
  <Content type="html"><![CDATA[
<script src="//plus.google.com/hangouts/_/api/v1/hangout.js"></script>
<script>
  var my_id = 0;
  var my_hangout_id = 0;

  function init() {
    // When API is ready...
    gapi.hangout.onApiReady.add(function (eventObj) {
      if (eventObj.isApiReady) {
        my_id = gapi.hangout.getLocalParticipant().person.id;
        my_hangout_id = gapi.hangout.getLocalParticipant().id;

        update_video_options();
        gapi.hangout.onParticipantsChanged.add(function () {
          update_video_options();
        });
      }
    });
  }

  function update_video_options () {
    var participants = gapi.hangout.getParticipants();

    for (var n = 0; n < participants.length; n++) {
      if (participants[n].person.id == my_id) {
        gapi.hangout.av.setParticipantAudible(participants[n].id, false);
        if ((participants.length != 2) && (participants[n].id != my_hangout_id)) {
          gapi.hangout.av.setParticipantVisible(participants[n].id, false);
          gapi.hangout.av.setAvatar(participants[n].id, 'http://fakeurl.com/logo.png');
        }
      }

      if (participants.length == 2) {
        gapi.hangout.av.setParticipantVisible(participants[n].id, true);
      }
    }

    var feed = gapi.hangout.layout.getDefaultVideoFeed();
    if ((gapi.hangout.getParticipantById(feed.getDisplayedParticipant()).person.id == my_id) && (participants.length == 3)) {
      // just in case we get stuck on a blocked user's feed, force switch to the other person
      for (var n = 0; n < participants.length; n++) {
        if (participants[n].person.id != my_id) {
          feed.setDisplayedParticipant(participants[n].id);
          n = participants.length;
        }
      }
    } else {
      feed.clearDisplayedParticipant();
    }
  }

  gadgets.util.registerOnLoadHandler(init);
</script>
]]>
  </Content>
</Module>

The whole thing is JavaScript wrapped in an XML container.  I’ll ignore the XML because it’s pretty standard, aside from the title attribute of the ModulePrefs element being “TSC Interact! Hangout Video Controller” (TSC being the company abbreviation, Interact! being the internal team name).

<script src="//plus.google.com/hangouts/_/api/v1/hangout.js"></script>
<script>
  var my_id = 0;
  var my_hangout_id = 0;

We start by pulling in the Hangouts API JS from Google, then we open our own script block. The first thing we do is define my_id as 0 and my_hangout_id as 0. We’ll store the user’s ID and the Hangout ID (which is the user/machine’s unique connection to a Hangout) in these spots later.

function init() {
  // When API is ready...
  gapi.hangout.onApiReady.add(function (eventObj) {
    if (eventObj.isApiReady) {
      my_id = gapi.hangout.getLocalParticipant().person.id;
      my_hangout_id = gapi.hangout.getLocalParticipant().id;

      update_video_options();
      gapi.hangout.onParticipantsChanged.add(function () {
        update_video_options();
      });
    }
  });
}

We define an init() function that we’ll fire off later. In it, we use gapi.hangout.onApiReady to attach this code to the event of the API being loaded and ready for us to use. If eventObj.isApiReady is true (and it should be, because this should only be fired if the API is ready, but I went off some sample code that included this), we can do some stuff.

“Some stuff” is setting my_id to gapi.hangout.getLocalParticipant().person.id and my_hangout_id to gapi.hangout.getLocalParticipant().id. Then we fire off the update_video_options() function and set a listener on gapi.hangout.onParticipantsChanged to run that function again each time someone enters or leaves the Hangout.

function update_video_options () {
  var participants = gapi.hangout.getParticipants();

  for (var n = 0; n < participants.length; n++) {
    if (participants[n].person.id == my_id) {
      gapi.hangout.av.setParticipantAudible(participants[n].id, false);
      if ((participants.length != 2) && (participants[n].id != my_hangout_id)) {
        gapi.hangout.av.setParticipantVisible(participants[n].id, false);
        gapi.hangout.av.setAvatar(participants[n].id, 'http://fakeurl.com/logo.png');
      }
    }

    if (participants.length == 2) {
      gapi.hangout.av.setParticipantVisible(participants[n].id, true);
    }
  }

Here’s the bread and butter, the update_video_options() function. We start by using gapi.hangout.getParticipants() to get the list of Hangout participants, then we loop through it to find users with the same ID as the machine this is running on.

If the user is the same, we use gapi.hangout.av.setParticipantAudible() to mute them by passing in the user ID and false. If there are not only two users in the Hangout (which means someone other than the two local machines is logged in) and the user we’re looping through is not the machine this is running on, we also hide the user and reset their avatar. Hiding the user, much like muting them, is done by calling gapi.hangout.av.setParticipantVisible() and passing in the user ID and false. To change the avatar we call gapi.hangout.av.setAvatar() and pass in the user ID and a URL to the new avatar.

If there are only two participants in the Hangout (meaning the two local stations are the only thing logged in), we may as well show the other participant. We do the opposite of what we did to hide them, calling gapi.hangout.av.setParticipantVisible() and passing in the user ID and true.

  var feed = gapi.hangout.layout.getDefaultVideoFeed();
  if ((gapi.hangout.getParticipantById(feed.getDisplayedParticipant()).person.id == my_id) && (participants.length == 3)) {
    // just in case we get stuck on a blocked user's feed, force switch to the other person
    for (var n = 0; n < participants.length; n++) {
      if (participants[n].person.id != my_id) {
        feed.setDisplayedParticipant(participants[n].id);
        n = participants.length;
      }
    }
  } else {
    feed.clearDisplayedParticipant();
  }
}

We wrap up the update_video_options() function by making sure we don’t somehow get stuck on the video feed for the other local station if someone else is available to see. We use gapi.hangout.layout.getDefaultVideoFeed() to get some data about the displayed video feed. If the displayed participant (getDisplayedParticipant()) is the same user as the machine this is running on and there are three participants to choose from (the two local stations plus someone remote), we want to make a switch. We loop through the participants to find one that isn’t the same user, then we use setDisplayedParticipant() to set the video feed to that user. We re-check the number of participants before looping again because of some API weirdness I can’t explain.

Lastly, if we’re not displaying the other local station or there are multiple remote users to choose from, we clear the displayed participant with clearDisplayedParticipant() and let the Hangout decide who should be shown. Because the other local machine won’t be shown at this point and is muted, it allows whichever remote user is talking to appear.

gadgets.util.registerOnLoadHandler(init);

The last thing we do is use gadgets.util.registerOnLoadHandler() to set the init() function to run when the utility is fully loaded.

One thing I think I’d do differently if this were still in use is update it to handle a variable number of local stations. There’s no reason not to account for three or four or whatever, aside from the fact that there were only two when I wrote it.

Batch Requests with the Trello API

I write about the Trello API a lot.  So much that I’ve already noted that I write about it a lot. I keep coming across new stuff, though, so I’m going to keep writing about it.

My latest experiment was a look into using their batch call, which I don’t see a lot of documentation about so I figured was worth writing up.

Batch functionality lets you fire off a series of GET requests (and they have to be GET requests) as one request.  Depending on your code and what requests you’re making (and what data you’re getting back), this should speed things up a bit.  My test script went from running in 37 seconds to 20, for example.

In an extremely simple (and pretty much useless) case, you could replace GET calls to /1/members/me and /1/boards/511e8c0101d3982d05000d5b with a single batch call, /1/batch?urls=/members/me,/boards/511e8c0101d3982d05000d5b.

As shown, /1/batch takes the URLs parameter, a comma-separated list of the calls you want to make, minus their version number prefix.

Of course, this means you get only a single response back, and it looks a little different from a normal response.  The response is an array of objects – but not of the normal response objects you might expect.  Instead, it’s an object with a single property, with a name set to the HTTP response code of the request.

So if your first request was to /1/boards/511e8c0101d3982d05000d5b, a normal response would start as follows:

stdClass Object
(
    [id] => 511e8c0101d3982d05000d5b
    [name] => Welcome Board

The batch version of that response would look like this:

Array
(
    [0] => stdClass Object
        (
            [200] => stdClass Object
                (
                    [id] => 511e8c0101d3982d05000d5b
                    [name] => Welcome Board

Obviously that’s simplified, I just don’t think it’s necessary to show the whole response.

One nice little gotcha with that response and working in PHP is handling a numeric property name, which is done by putting curly braces around the number, as seen in the code to follow.

Lets say you want to get the names and IDs of all the boards you’re assigned to and the names and IDs of all of the lists on each of those boards.  Without batching, you could do the following:

  $trello = new trello_api($GLOBALS['config']['key'], $GLOBALS['config']['secret'], $GLOBALS['config']['token']);

  $data = $trello->request('GET', '/1/members/me');
  foreach ($data->idBoards AS $board_id) {
    $board_data = $trello->request('GET', ('/1/boards/' . $board_id));
    echo '<p>' . $board_data->name . ': ' . $board_id . '</p>';
    echo '<ul>';

    $list_data = $trello->request('GET', ('/1/boards/' . $board_id . '/lists'));
    foreach ($list_data AS $list) {
      echo '<li>' . $list->name . ': ' . $list->id . '</li>';
    }

    echo '</ul>';
  }

  unset($trello);

With batching, that becomes this:

  $trello = new trello_api($GLOBALS['config']['key'], $GLOBALS['config']['secret'], $GLOBALS['config']['token']);

  $batch = array();

  $data = $trello->request('GET', '/1/members/me');
  foreach ($data->idBoards AS $board_id) {
    $batch[] = ('/boards/' . $board_id);
    $batch[] = ('/boards/' . $board_id . '/lists');
  }

  $data = $trello->request('GET', ('/1/batch?urls=' . implode(',', $batch)));
  for ($n = 0; $n < count($data); $n += 2) {
    echo '<p>' . $data[$n]->{200}->name . ': ' . $data[$n]->{200}->id . '</p>';

    echo '<ul>';

    foreach ($data[($n + 1)]->{200} AS $list) {
      echo '<li>' . $list->name . ': ' . $list->id . '</li>';
    }

    echo '</ul>';
  }

  unset($trello);

This assumes that the API will respond with a 200, of course.

As I said, I didn’t see a ton of documentation about batch calls in the Trello API.  This is a stupid simple example but I thought it was worth putting out there.

Retro Code Sample: Press Your Luck

I was going through my portfolio recently and realized that I have an entry for my Press Your Luck game but I’ve only described how it works, never taken a deep dive into the code.

The current version (if you can call something no longer in use “current”) runs entirely on the client side.  There is one HTML file (with inline jQuery), one CSS file, an XML file with configuration values, and a handful of images and sounds.

Some parts of these files have been modified for display purposes.  None of the changes impact functionality.

We’ll start with the config file…

<?xml version="1.0" encoding="utf-8" ?>
<images>
  <image>
    <thumb>http://pressyourluck.info/images/prize_choices_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_choices_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_giftcard_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_giftcard_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_lunch_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_lunch_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_mug_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_mug_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_parking_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_parking_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_swagbag_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_swagbag_full.jpg</large>
    <type>prize</type>
  </image>
  <image>
    <thumb>http://pressyourluck.info/images/prize_whammy1_thumb.jpg</thumb>
    <large>http://pressyourluck.info/images/prize_whammy1_full.jpg</large>
    <type>whammy</type>
  </image>
</images>

We’re defining a set of images, the tiles that make up the game board.  Each has a thumbail (the image displayed on the standard game board) and a full-size image (the one displayed in the center when that tile is selected by the player) and we define their URLs here.  We also define whether this is a prize image or a whammy, which determines what sound plays when that tile is selected.

Fairly simple.  Now we move on to the CSS…

body {
	margin: 0;
	padding: 0;
	background-image: url(./images/bg_page.jpg);
	background-repeat: no-repeat;
	background-position: center;
	background-color: #1a1a1a;
}

div#board {
	position: relative;
	margin: 30px auto 30px auto;
	padding: 0;
	width: 1002px;
	height: 800px;
	border: 5px solid #000;
	border-radius: 12px;
	background-color: #000;
}

div#board div {
	width: 167px;
	height: 160px;
	
}

div#board div img {
	position: relative;
	width: 150px;
	height: 140px;
	margin: 8px 6px 8px 7px;
	padding: 0;
	border: 2px solid #000;
}

div#board div.inactive {
	background-image: url(./images/bg_item_inactive.jpg);
	opacity: 0.35;
}

div#board div.active {
	background-color: yellow;
	background-image: url(./images/bg_item_active.gif);
	opacity: 1;
}

div#board div#item0 {
	position: absolute;
	left: 0;
	top: 0;
}

div#board div#item1 {
	position: absolute;
	left: 167px;
	top: 0;
}

div#board div#item2 {
	position: absolute;
	left: 334px;
	top: 0;
}

div#board div#item3 {
	position: absolute;
	left: 501px;
	top: 0;
}

div#board div#item4 {
	position: absolute;
	left: 668px;
	top: 0;
}

div#board div#item5 {
	position: absolute;
	left: 835px;
	top: 0;
}

div#board div#item6 {
	position: absolute;
	left: 0;
	top: 160px;
}

div#board div#item7 {
	position: absolute;
	left: 835px;
	top: 160px;
}

div#board div#item8 {
	position: absolute;
	left: 0;
	top: 320px;
}

div#board div#item9 {
	position: absolute;
	left: 835px;
	top: 320px;
}

div#board div#item10 {
	position: absolute;
	left: 0;
	top: 480px;
}

div#board div#item11 {
	position: absolute;
	left: 835px;
	top: 480px;
}

div#board div#item12 {
	position: absolute;
	left: 0;
	top: 640px;
}

div#board div#item13 {
	position: absolute;
	left: 167px;
	top: 640px;
}

div#board div#item14 {
	position: absolute;
	left: 334px;
	top: 640px;
}

div#board div#item15 {
	position: absolute;
	left: 501px;
	top: 640px;
}

div#board div#item16 {
	position: absolute;
	left: 668px;
	top: 640px;
}

div#board div#item17 {
	position: absolute;
	left: 835px;
	top: 640px;
}

div#board div#item_middle {
	position: absolute;
	left: 167px;
	top: 160px;
	width: 668px;
	height: 520px;
}

div#board div#item_middle img {
	position: relative;
	width: 648px;
	height: 460px;
	margin: 10px;
	padding: 0;
	border: 0;
}

div#board div#item_middle img.prize {
	width: 514px;
	height: 480px;
	margin: 0 77px 0 77px;
}

div#sound {
	width: 0;
	height: 0;
	overflow: hidden;
}

More pretty simple stuff. The page has a background. There’s a div that contains all the game elements. Those are positioned as needed. The tiles have a background image for their active and inactive states. The sound controls are hidden.

Now we get to the fun, the HTML and jQuery. Here’s the full page, we’ll break down the important parts afterwards…

<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <title>Bravo Press Your Luck</title>
    <style type="text/css" media="screen">
      @import "./styles.css";
    </style>
    <link href="./jquery-ui/css/custom-theme/jquery-ui-1.8.16.custom.css" rel="stylesheet" type="text/css" />
    <script src="./jquery-ui/js/jquery-1.6.2.min.js" type="text/javascript"></script>
    <script src="./jquery-ui/js/jquery-ui-1.8.16.custom.min.js" type="text/javascript"></script>
  </head>
  <body>
    <div id="board">
      <div id="item0" class="inactive">
        <img src="./images/space.gif" id="image0" />
      </div>
      <div id="item1" class="inactive">
        <img src="./images/space.gif" id="image1" />
      </div>
      <div id="item2" class="inactive">
        <img src="./images/space.gif" id="image2" />
      </div>
      <div id="item3" class="inactive">
        <img src="./images/space.gif" id="image3" />
      </div>
      <div id="item4" class="inactive">
        <img src="./images/space.gif" id="image4" />
      </div>
      <div id="item5" class="inactive">
        <img src="./images/space.gif" id="image5" />
      </div>
      <div id="item6" class="inactive">
        <img src="./images/space.gif" id="image6" />
      </div>
      <div id="item7" class="inactive">
        <img src="./images/space.gif" id="image7" />
      </div>
      <div id="item8" class="inactive">
        <img src="./images/space.gif" id="image8" />
      </div>
      <div id="item9" class="inactive">
        <img src="./images/space.gif" id="image9" />
      </div>
      <div id="item10" class="inactive">
        <img src="./images/space.gif" id="image10" />
      </div>
      <div id="item11" class="inactive">
        <img src="./images/space.gif" id="image11" />
      </div>
      <div id="item12" class="inactive">
        <img src="./images/space.gif" id="image12" />
      </div>
      <div id="item13" class="inactive">
        <img src="./images/space.gif" id="image13" />
      </div>
      <div id="item14" class="inactive">
        <img src="./images/space.gif" id="image14" />
      </div>
      <div id="item15" class="inactive">
        <img src="./images/space.gif" id="image15" />
      </div>
      <div id="item16" class="inactive">
        <img src="./images/space.gif" id="image16" />
      </div>
      <div id="item17" class="inactive">
        <img src="./images/space.gif" id="image17" />
      </div>
      <div id="item_middle">
        <img src="./images/space.gif" id="image_middle" />
      </div>
    </div>

    <div id="sound">
      <audio id="player_board" name="player_board" preload="auto" loop="loop">
        <source src="./sounds/board.ogg" />
        <source src="./sounds/board.mp3" />
      </audio>
      <audio id="player_buzz" name="player_buzz" preload="auto">
        <source src="./sounds/buzz.ogg" />
        <source src="./sounds/buzz.mp3" />
      </audio>
      <audio id="player_whammy" name="player_whammy" preload="auto">
        <source src="./sounds/whammy.ogg" />
        <source src="./sounds/whammy.mp3" />
      </audio>
    </div>

    <script type="text/javascript">
    //<![CDATA[
      $(document).ready(function () {
        var images = define_board_images();
        window.game_data = build_game_boards(images);
        load_game_board();
      });

      function define_board_images () {
        var images = new Array();

        $.ajax({
          url: 'config.xml',
          dataType: 'xml',
          async: false,
          success: function (data) {
            $($(data)).find('image').each(function () {
              images.push({'thumb': $(this).find('thumb').text(), 'large': $(this).find('large').text(), 'type': $(this).find('type').text()});
            });
          }
        });

        return images;
      }

      function build_game_boards (images) {
        var boards = new Array();

        do {
          var set = new Array();

          do {
            images = array_shuffle(images);
            var n = 0;

            do {
              set.push(images[n]);
              n++;
            } while ((set.length < 18) && (n < images.length));
          } while (set.length < 18);

          boards.push(set);
        } while (boards.length < 50);

        return boards;
      }

      function load_game_board () {
        $('div#board div.active').removeClass('active');
        $('img#image_middle').removeClass('prize');
        $('img#image_middle').attr('src', './images/logo.jpg');

        window.active_set = -1;
        window.active_space = -1;

        print_set(get_random_set(false));

        $(document).keydown(function (e) {
          if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
            e.preventDefault();
            start_game();
          }
        });

        $(document).bind('touchend', function (e) {
          e.preventDefault();
          start_game();
        });
      }

      function get_random_set (no_repeat) {
        var r = -1;

        if (no_repeat) {
          do {
            r = Math.floor(Math.random() * window.game_data.length);
          } while (r == window.active_set);
          window.active_set = r;
        } else {
          r = Math.floor(Math.random() * window.game_data.length);
        }
        return window.game_data[r];
      }

      function print_set (data) {
        $('div#board div img').each(function (index) {
          if ($(this).attr('id') != 'image_middle') {
            $(this).attr('src', data[index].thumb)
            $(this).attr('data-type', data[index].type)
            $(this).attr('data-large', data[index].large)
          }
        });
      }

      function move_active_space () {
        var r = -1;

        do {
          r = Math.floor(Math.random() * 18);
        } while (r == window.active_space);

        window.active_space = r;
        var new_selector = 'div#item' + r;

        $('div.active').removeClass('active');
        $(new_selector).addClass('active');
      }

      function start_game () {
        window.active_set = -1;
        window.active_space = -1;

        $(document).unbind();

        $(document).keydown(function (e) {
          if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
            e.preventDefault();
            stop_game();
          }
        });

        $(document).bind('touchend', function (e) {
          e.preventDefault();
          stop_game();
        });

        $('#player_board')[0].play();

        window.interval_set = setInterval(function () { print_set(get_random_set(true)); }, 850);
        window.interval_space = setInterval(function () { move_active_space(); }, 500);
      }

      function stop_game () {
        clearInterval(interval_set);
        clearInterval(interval_space);

        $(document).unbind();

        $(document).keydown(function (e) {
          if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
            e.preventDefault();
            load_game_board();
          }
        });

        $(document).bind('touchend', function (e) {
          e.preventDefault();
          load_game_board();
        });

        var winning_cell = 'div#item' + window.active_space;

        $('#player_board')[0].pause();
        $('#player_board')[0].currentTime = 0;

        if ($(winning_cell + ' img').attr('data-type') == 'whammy') {
          $('#player_whammy')[0].play();
        } else {
          $('#player_buzz')[0].play();
        }

        $(winning_cell).removeClass('active').delay(100).queue(function (next) {
          $(this).addClass('active').delay(100).queue(function (next) {
            $(winning_cell).removeClass('active').delay(100).queue(function (next) {
              $(this).addClass('active').delay(100).queue(function (next) {
                $(winning_cell).removeClass('active').delay(100).queue(function (next) {
                  $('img#image_middle').attr('src', './images/space.gif');
                  $('img#image_middle').addClass('prize');

                  $(this).addClass('active').delay(100).queue(function (next) {
                    $(winning_cell).removeClass('active').delay(100).queue(function (next) {
                      $('img#image_middle').attr('src', $(winning_cell + ' img').attr('data-large'));

                      $(this).addClass('active').delay(100).queue(function (next) {
                        $(winning_cell).removeClass('active').delay(100).queue(function (next) {
                          $(this).addClass('active').delay(100).queue(function (next) {
                            next();
                          });
                          next();
                        });
                        next();
                      });
                      next();
                    });
                    next();
                  });
                  next();
                });
                next();
              });
              next();
            });
            next();
          });
          next();
        });
      }

      function array_shuffle (orig_array) {
        var shuffled_array = orig_array.slice();
        var len = shuffled_array.length;
        var i = len;
        while (i--) {
          var p = parseInt(Math.random()*len);
          var t = shuffled_array[i];
          shuffled_array[i] = shuffled_array[p];
          shuffled_array[p] = t;
        }
        return shuffled_array; 
      };
    //]]>
    </script>
  </body>
</html>

Get the basic stuff out of the way… We import our CSS. We import jQuery UI. We lay out the game board and we set up some audio elements for the game sounds (which I pulled from some site that had all sorts of game show sounds archived, I can’t remember where it was).

$(document).ready(function () {
  var images = define_board_images();
  window.game_data = build_game_boards(images);
  load_game_board();
});

The first thing we do is initialize some stuff. Define our board images, build our possible game boards, throw a board onto the screen. Now let’s see how we do that.

function define_board_images () {
  var images = new Array();

  $.ajax({
    url: 'config.xml',
    dataType: 'xml',
    async: false,
    success: function (data) {
      $($(data)).find('image').each(function () {
        images.push({'thumb': $(this).find('thumb').text(), 'large': $(this).find('large').text(), 'type': $(this).find('type').text()});
      });
    }
  });

  return images;
}

We’re loading that config file, then looping through each “image” element to find the “thumb”, “large”, and “type” definitions we discussed earlier. Then we’re dropping those into an array.

When I wrote this I was shocked that there wasn’t an easier way to do this using XML. If it were similarly-structured JSON, it’d just parse automatically. Instead I have to do it manually. Considering what the X in AJAX stands for, I expected more out-of-the-box support for XML. Maybe I’m just missing something.

function build_game_boards (images) {
  var boards = new Array();

  do {
    var set = new Array();

    do {
      images = array_shuffle(images);
      var n = 0;

      do {
        set.push(images[n]);
        n++;
      } while ((set.length < 18) && (n < images.length));
    } while (set.length < 18);

    boards.push(set);
  } while (boards.length < 50);

  return boards;
}

With our available images defined, we cache a set of fifty possible game boards. We do this by shuffling the array of images (using a function I just grabbed from somewhere else) and adding them in order to a new set until there are 18 in that set. If we run out before we get to 18, we shuffle again and keep going. This means we can have as many or as few (as long as there’s at least one) images configured.

function load_game_board () {
  $('div#board div.active').removeClass('active');
  $('img#image_middle').removeClass('prize');
  $('img#image_middle').attr('src', './images/logo.jpg');

  window.active_set = -1;
  window.active_space = -1;

  print_set(get_random_set(false));

  $(document).keydown(function (e) {
    if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
      e.preventDefault();
      start_game();
    }
  });

  $(document).bind('touchend', function (e) {
    e.preventDefault();
    start_game();
  });
}

Finally we load the game board. We make sure no tiles are active, we set the middle image back to our placeholder, we get a randomly-selected one of our cached tile sets and display it on the board. Then we define some key events that allow the game to be controlled from the keyboard or from a presentation mouse, so that any event will trigger the start of the game. We bind the same action on touchend so that the person who commissioned this can play on her phone.

function get_random_set (no_repeat) {
  var r = -1;

  if (no_repeat) {
    do {
      r = Math.floor(Math.random() * window.game_data.length);
    } while (r == window.active_set);
    window.active_set = r;
  } else {
    r = Math.floor(Math.random() * window.game_data.length);
  }
  return window.game_data[r];
}

Our function for getting a random set is simple enough. Get a random number from 0 to the size of the set (should always be 50). If we don’t want to allow the same set to be picked twice in a row, compare that number to the current one and do it again until we get something different. Return the set of images with that number as the key.

function print_set (data) {
  $('div#board div img').each(function (index) {
    if ($(this).attr('id') != 'image_middle') {
      $(this).attr('src', data[index].thumb)
      $(this).attr('data-type', data[index].type)
      $(this).attr('data-large', data[index].large)
    }
  });
}

To print out the board, we loop through each image on the board that isn’t the one in the middle. We use the index of the image and pull from the array we set in get_random_set() to reset said image’s attributes.

function start_game () {
  window.active_set = -1;
  window.active_space = -1;

  $(document).unbind();

  $(document).keydown(function (e) {
    if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
      e.preventDefault();
      stop_game();
    }
  });

  $(document).bind('touchend', function (e) {
    e.preventDefault();
    stop_game();
  });

  $('#player_board')[0].play();

  window.interval_set = setInterval(function () { print_set(get_random_set(true)); }, 850);
  window.interval_space = setInterval(function () { move_active_space(); }, 500);
}

Ahh, yes, now we start the actual gameplay. We wipe out all of the events we set earlier and set new ones on the same triggers, this time for stopping the game. We start playing our in-game music. Then we set an interval to reload the game board every 850 milliseconds (allowing for the same board to be played twice in a row this time) and for the active tile to shift every half-second. I got those numbers from watching way too much Press Your Luck.

function move_active_space () {
  var r = -1;

  do {
    r = Math.floor(Math.random() * 18);
  } while (r == window.active_space);

  window.active_space = r;
  var new_selector = 'div#item' + r;

  $('div.active').removeClass('active');
  $(new_selector).addClass('active');
}

How do we switch the active tile? Well we know there are 18 tiles so we randomly select a number 0 to 17 until that number is not the same as the one we’ve already got. Then we remove the active class from whatever tile is active and add it to the one that corresponds to our randomly-selected number.

Our last step is to stop the game and it’s made up of a bunch of little things.

clearInterval(interval_set);
clearInterval(interval_space);

$(document).unbind();

$(document).keydown(function (e) {
  if ((e.keyCode == 32) || (e.keyCode == 33) || (e.keyCode == 34) || (e.keyCode == 66)) {
    e.preventDefault();
    load_game_board();
  }
});

$(document).bind('touchend', function (e) {
  e.preventDefault();
  load_game_board();
});

First we clear our intervals so the game won’t continue, then we wipe out our event bindings and set up new ones for the same triggers. These new ones will reset the game board and get us in a position to start a new game.

var winning_cell = 'div#item' + window.active_space;

$('#player_board')[0].pause();
$('#player_board')[0].currentTime = 0;

if ($(winning_cell + ' img').attr('data-type') == 'whammy') {
  $('#player_whammy')[0].play();
} else {
  $('#player_buzz')[0].play();
}

We get the winning tile and stop the in-game music. Based on what type of image that winning tile is, we play either the “buzz-in” sound or the “whammy” sound.

$(winning_cell).removeClass('active').delay(100).queue(function (next) {
  $(this).addClass('active').delay(100).queue(function (next) {
    $(winning_cell).removeClass('active').delay(100).queue(function (next) {
      $(this).addClass('active').delay(100).queue(function (next) {
        $(winning_cell).removeClass('active').delay(100).queue(function (next) {
          $('img#image_middle').attr('src', './images/space.gif');
          $('img#image_middle').addClass('prize');

          $(this).addClass('active').delay(100).queue(function (next) {
            $(winning_cell).removeClass('active').delay(100).queue(function (next) {
              $('img#image_middle').attr('src', $(winning_cell + ' img').attr('data-large'));

              $(this).addClass('active').delay(100).queue(function (next) {
                $(winning_cell).removeClass('active').delay(100).queue(function (next) {
                  $(this).addClass('active').delay(100).queue(function (next) {
                    next();
                  });
                  next();
                });
                next();
              });
              next();
            });
            next();
          });
          next();
        });
        next();
      });
      next();
    });
    next();
  });
  next();
});

This is how we make the lights around the winning tile flash and it’s ugly. We add and remove the “active” class from that tile in 100 millisecond intervals. Partway through that, we change the center image on the game board to match that of the winning tile. Again, those times were selected from watching way too much Press Your Luck.

And that’s really all there is to it.  There may be a better way by now (I hope there is for that flashing bit) but this is what I knew at the time.  It was a lot of fun to write and it was a lot of fun to see people play.

Linking Child and Parent Cards on Trello – Part 2

Last week I published a bit of code that uses the Trello API to keep parent and child cards synced across a set of boards.  It was a little piece of research that has absolutely taken off around the office so I’ve been expanding on it and demoing it and talking about it and generally losing my mind.

The thing I expanded on most is a flaw that appeared in my original script whereby a user could create a child card outside of the normal workflow and it would never be linked to the parent card.  Obviously “outside of the normal workflow” means it’s already an edge case but that doesn’t mean it’s as uncommon as we’d like, so I came up with a way to handle it.  It does rely on the child card being tagged with the same card tag as the parent but it’s better than nothing.

#!/usr/bin/php -q
<?php
  $trello = new trello_api($GLOBALS['config']['key'], $GLOBALS['config']['secret'], $GLOBALS['config']['token']);

  // LOOP THROUGH EACH CARD ON THE WIP BOARD TO GET THE PARENT CARD TAGS
  $parent = array();

  $card_data = $trello->request('GET', ('/1/boards/' . $GLOBALS['config']['board']['wip_prototype']['id'] . '/cards'));
  foreach ($card_data AS $card) {
    $card_tag = '';
    if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
      $card_tag = $matches[1];
    }

    if ($card_tag) {
      $parent[$card_tag] = array('id' => $card->id, 'url' => $card->shortUrl, 'label' => $card->labels[0]->color);
    }
  }

  // LOOP THROUGH EACH CHILD BOARD
  foreach ($GLOBALS['config']['child_boards'] AS $board_name) {
    // LOOP THROUGH ALL THE CARDS ON THE BOARD
    $card_data = $trello->request('GET', ('/1/boards/' . $GLOBALS['config']['board'][$board_name]['id'] . '/cards'));
    foreach ($card_data AS $card) {
      if (!preg_match('|Parent Card: https://trello.com/c/(.{8})|is', $card->desc)) {
        // NO PARENT CARD LINKED
        $card_tag = '';
        if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
          $card_tag = $matches[1];
        }

        if ($card_tag) {
          // WE'VE GOT A TAG, LET'S USE IT
          if (is_array($parent[$card_tag])) {
            // WE KNOW THE PARENT THIS BELONGS TO

            // UPDATE THE CHILD CARD
            $trello->request('PUT', ('/1/cards/' . $card->id), array('desc' => (trim('Parent Card: ' . $parent[$card_tag]['url'] . ' ' . $card->desc))));

            // UPDATE THE PARENT CARD LABEL IF NECESSARY
            if (!$parent[$card_tag]['label']) {
              $trello->request('PUT', ('/1/cards/' . $parent[$card_tag]['id'] . '/labels'), array('value' => $GLOBALS['config']['label'][$board_name]));
            }

            // GET THE SLICES CHECKLIST
            $checklist_id = 0;
            $checklist_data = $trello->request('GET', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklists'));
            foreach ($checklist_data AS $checklist) {
              if ($checklist->name == 'Slices') {
                $checklist_id = $checklist->id;
              }
            }

            if (!$checklist_id) {
              // NO SLICES CHECKLIST FOUND, MAKE ONE
              $new_checklist = $trello->request('POST', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklists'), array('name' => 'Slices'));
              $checklist_id = $new_checklist->id;
            }

            // ADD THE CHECKLIST ITEM TO THE SLICES CHECKLIST
            $trello->request('POST', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklist/' . $checklist_id . '/checkItem'), array('name' => $card->shortUrl));
          }
        }
      }
    }
  }

  unset($trello);
?>

As with my previous post, this uses my Trello API wrapper class and pulls in the $GLOBALS[‘config’] array of configuration values from another file.  Also as with my previous post I think it’s commented pretty well but we’re going through the code piece-by-piece anyway.

// LOOP THROUGH EACH CARD ON THE WIP BOARD TO GET THE PARENT CARD TAGS
$parent = array();

$card_data = $trello->request('GET', ('/1/boards/' . $GLOBALS['config']['board']['wip_prototype']['id'] . '/cards'));
foreach ($card_data AS $card) {
  $card_tag = '';
  if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
    $card_tag = $matches[1];
  }

  if ($card_tag) {
    $parent[$card_tag] = array('id' => $card->id, 'url' => $card->shortUrl, 'label' => $card->labels[0]->color);
  }
}

We loop through all of the cards on our Work in Progress (“WIP”) board and use a regular expression to see if they have a card tag as a prefix (appearing in the pattern of “[TEST4] Test Project 4”). If the card does, we save an array of data about the parent to an array of parent cards for reference by card tag later.

// LOOP THROUGH EACH CHILD BOARD
foreach ($GLOBALS['config']['child_boards'] AS $board_name) {
  // LOOP THROUGH ALL THE CARDS ON THE BOARD
  $card_data = $trello->request('GET', ('/1/boards/' . $GLOBALS['config']['board'][$board_name]['id'] . '/cards'));
  foreach ($card_data AS $card) {
    if (!preg_match('|Parent Card: https://trello.com/c/(.{8})|is', $card->desc)) {

Then we loop through our list of child board names and get every card on that board using a GET request to /1/board/xxxxxx/cards (where xxxxxx is the board ID).  If the card’s description doesn’t match our convention for linking back to a parent, we know we’ve found a rogue card.

$card_tag = '';
if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
  $card_tag = $matches[1];
}

if ($card_tag) {
  // WE'VE GOT A TAG, LET'S USE IT
  if (is_array($parent[$card_tag])) {
    // WE KNOW THE PARENT THIS BELONGS TO

We check to see if the card has a tag in its name, using the same regular expression as we did earlier.  If it does, we can use it to move forward. If we know the parent that tag belongs to, we can do even more.

// UPDATE THE CHILD CARD
$trello->request('PUT', ('/1/cards/' . $card->id), array('desc' => (trim('Parent Card: ' . $parent[$card_tag]['url'] . ' ' . $card->desc))));

The first thing we do is fire off a PUT request to /1/cards/yyyyyy (where yyyyyy is the ID of the rogue card) with desc set to the current description with our parent link prepended to it.  This gives our child card the necessary link to the parent.

// UPDATE THE PARENT CARD LABEL IF NECESSARY
if (!$parent[$card_tag]['label']) {
  $trello->request('PUT', ('/1/cards/' . $parent[$card_tag]['id'] . '/labels'), array('value' => $GLOBALS['config']['label'][$board_name]));
}

On the off chance that the parent card doesn’t have a label, we use the fact that we already know what board the child card is on to set one.  That involves a PUT request to /1/cards/zzzzzz/labels (where zzzzzz is the ID of the parent card) with value set to the color name of the label that corresponds to the board.

// GET THE SLICES CHECKLIST
$checklist_id = 0;
$checklist_data = $trello->request('GET', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklists'));
foreach ($checklist_data AS $checklist) {
  if ($checklist->name == 'Slices') {
    $checklist_id = $checklist->id;
  }
}

Then we get ID of the parent card’s “Slices” checklist, as that’s where the parent card links to each of it’s children.  We make a GET request to /1/cards/zzzzzz/checklists and loop through each one until we find the one with the right name, then save that ID off for later.

if (!$checklist_id) {
  // NO SLICES CHECKLIST FOUND, MAKE ONE
  $new_checklist = $trello->request('POST', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklists'), array('name' => 'Slices'));
  $checklist_id = $new_checklist->id;
}

What if we didn’t get a checklist ID?  Then we make one.  We fire off a POST request to /1/cards/zzzzzz/checklists with name set to “Slices” and that gives us back a bunch of data about a newly-created checklist.  We save off the new checklist ID so we can move forward.

// ADD THE CHECKLIST ITEM TO THE SLICES CHECKLIST
$trello->request('POST', ('/1/cards/' . $parent[$card_tag]['id'] . '/checklist/' . $checklist_id . '/checkItem'), array('name' => $card->shortUrl));

And our last step of the loop is to link the parent card to the rogue child.  We fire off a POST request to /1/cards/zzzzzz/checklist/cccccc/checkItem (where cccccc is the “Slices” checklist ID) with name set to the URL of the rogue child card.  Trello’s interface will convert that to the name of the child card when the parent card is viewed.

As I mentioned in my previous post, this is my first pass and I’m sure there’s a better way to do this.  This fixes a gap in that earlier implementation, though, so obviously iterating on it is working.

Linking Child and Parent Cards on Trello via the Trello API

One of the things my team at work always seems to have a problem with is tracking our tasks and how they fit into the bigger picture of our team’s goals.  We had these problems when we were using a bulletin board, we have these problems now that we’re using a single Trello board.

We have a subset of the team currently researching new tools due to the perceived limitations of Trello. Being a developer and having never met a problem that couldn’t be solved by throwing code at it, I decided to see what could be done with the Trello API.

I’ve written about using the Trello API a couple times before.  It’s a powerful tool and it’s something I’ve had a lot of fun with.  This builds off of that work.

One thing that was the lynch-pin for figuring all of this out is the difference between a card’s ID, shortID, and shortLink.  Because shortLink isn’t returned by default when you make a request for a card’s information, I thought that it was just another term for shortID, much as how archived and closed are synonyms within the Trello environment.  It turns out that that’s not true, and since shortLink is exposed in every Trello URL, it opens up a lot of options.

Before getting into the code too much, though, a look at the problem we’re trying to solve…

Right now my team has one “Work in Progress” (or “WIP”) board with ten functional columns.  Requests Not Started, Requests In Progress, Slices Not Started, Slices In Progress, Ready for QA on DEV, QA on Dev, Need to Publish to Stage, QA on Stage, Ready for Publish, and Published.

Requests are large-scale projects.  Slices are parts of a request, which are supposed to be individually-shippable.

When you pick up a request that hasn’t started yet, your first task is to break it into slices.  Those slices then march across the board and when they’re all published the request card jumps over to join them (currently a manual process).  The idea is that the slices show the team a little bit more of what’s in progress while the requests are geared towards external stakeholders.

The problem with that is that it makes for a messy board.  If only we could have a WIP board just for requests, and have the slices flowing along some other path, but still tied back to the request card.  So that’s what I did.

Here’s my informal demo video, with code to follow.

Okay, now on to the code.

#!/usr/bin/php -q
<?php
  $trello = new trello_api($GLOBALS['config']['key'], $GLOBALS['config']['secret'], $GLOBALS['config']['token']);

  // LOOP THROUGH EACH CARD ON THE REQUESTS IN PROGRESS LIST
  $card_data = $trello->request('GET', ('/1/lists/' . $GLOBALS['config']['board']['wip_prototype']['rip_list'] . '/cards'));
  foreach ($card_data AS $card) {
    // GET THE CHILD BOARD
    $child_board_name = '';
    foreach ($GLOBALS['config']['label'] AS $board => $color) {
      if ($card->labels[0]->color == $color) {
        $child_board_name = $board;
      }
    }

    // GET THE CARD TAG
    $card_tag = '';
    if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
      $card_tag = '[' . $matches[1] . '] ';
    }    

    // GET THE CHILD TASKS / CARDS
    $children = 0;
    $children_completed = 0;

    foreach ($card->idChecklists AS $checklist_id) {
      $checklist_data = $trello->request('GET', ('/1/checklists/' . $checklist_id));

      if ($checklist_data->name == 'Slices') {
        foreach ($checklist_data->checkItems AS $item) {
          if (preg_match('|https://trello.com/c/(.{8})|is', $item->name, $matches)) {
            // WE HAVE THE CHILD CARD, CHECK IT
            $child_card = $trello->request('GET', ('/1/cards/' . $matches[1]));

            if ($child_card->id) {
              // CARD STILL EXISTS
              $child_card_state = 'incomplete';
              if (($child_card->closed) OR ($child_card->idList == $GLOBALS['config']['board']['qa_prototype']['completed_list'])) {
                $child_card_state = 'complete';
                $children_completed++;
              }

              // UPDATE THE STATUS IF NECESSARY
              if ($child_card_state != $item->state) {
                $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/state'), array('value' => $child_card_state));
              }

              $children++;
            } else {
              // CARD HAS BEEN DELETED, REMOVE THE CHECKITEM
              $trello->request('DELETE', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id));
            }
          } else {
            // CARD HASN'T BEEN CREATED YET, DO IT IF WE HAVE A LIST TO POST TO
            if ($GLOBALS['config']['board'][$child_board_name]['ns_list']) {
              $new_card = $trello->request('POST', '/1/cards', array('name' => ($card_tag . $item->name), 'idList' => $GLOBALS['config']['board'][$child_board_name]['ns_list'], 'desc' => ('Parent Card: ' . $card->shortUrl)));

              if ($new_card->id) {
                // UPDATE THE CHECKLIST ITEM WITH THE LINK TO THE NEW CARD
                $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/name'), array('value' => $new_card->shortUrl));
                $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/state'), array('value' => 'incomplete'));
              }

              $children++;
            }
          }
        }
      }
    }

    if (($children > 0) AND ($children == $children_completed)) {
      $trello->request('PUT', ('/1/cards/' . $card->id . '/'), array('pos' => 'top', 'idList' => $GLOBALS['config']['board']['wip_prototype']['completed_list']));
    }
  }

  unset($trello);
?>

This makes use of my Trello API wrapper class and has several values (all part of the $GLOBALS[‘config’] array) dumped off to an included configuration file.  I think the comments are pretty good, but let’s go through this a little bit of code at a time.

The whole thing is wrapped in a loop through the cards on the Requests in Progress column of the main WIP board.  We get that by doing a GET request to /1/lists/xxxxxx/cards (where xxxxxx us the list’s ID) and I won’t bother detailing it any further.

// GET THE CHILD BOARD
$child_board_name = '';
foreach ($GLOBALS['config']['label'] AS $board => $color) {
  if ($card->labels[0]->color == $color) {
    $child_board_name = $board;
  }
}

// GET THE CARD TAG
$card_tag = '';
if (preg_match('|[(.*)](.*)|is', $card->name, $matches)) {
  $card_tag = '[' . $matches[1] . '] ';
}

We figure out which board the child cards should go to by looking at the labels and our configured array that matches label colors to child boards.  In this example, the green label belongs to the ecommerce board and the red one to the marketing board.  If the first label on the card matches the color of one of those boards, we’ve got our board.

Then we take a look at the parent card name and look for anything inside square brackets using a regular expression.  That’s the tag that should be applied to child cards, something we use for filtering.

We set the number of children on this card and the number of completed children to zero, values we’ll update as we go.  Then we loop through the checklist IDs we got when we looked up the card data.

$checklist_data = $trello->request('GET', ('/1/checklists/' . $checklist_id));

if ($checklist_data->name == 'Slices') {
  foreach ($checklist_data->checkItems AS $item) {

We use a GET request to /1/checklists/yyyyyy (where yyyyyy is the ID of our checklist) to get more info about that checklist.  If the name of the checklist is “Slices” then we iterate through each item because we know those represent our child cards.

if (preg_match('|https://trello.com/c/(.{8})|is', $item->name, $matches)) {
  // WE HAVE THE CHILD CARD, CHECK IT
  $child_card = $trello->request('GET', ('/1/cards/' . $matches[1]));

  if ($child_card->id) {
    // CARD STILL EXISTS
    $child_card_state = 'incomplete';
    if (($child_card->closed) OR ($child_card->idList == $GLOBALS['config']['board']['qa_prototype']['completed_list'])) {
      $child_card_state = 'complete';
      $children_completed++;
    }

    // UPDATE THE STATUS IF NECESSARY
    if ($child_card_state != $item->state) {
      $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/state'), array('value' => $child_card_state));
    }

    $children++;

If the text of the checklist item matches the pattern of a Trello card URL, we know it’s an already-linked child.  We get the shortLink from that URL using our regular expression and then make a GET request to /1/cards/zzzzzz, where zzzzzz is that shortLink.  If the card has an ID it means it still exists, so we can do some work with it.

By default, we set the card’s status to incomplete, which is one of the states of a checkItem in Trello. If the card is closed or is in the Published list on our QA board, we know work has been completed and the state can be set to complete. We can also increment our counter of completed child cards by one.  If the state we’ve determined doesn’t match that of the checkItem, we fire off a PUT request to /1/cards/cccccc/checklist/bbbbbb/checkItem/aaaaaa/state (wherre cccccc is the card ID, bbbbbb is the checklist ID and aaaaaa is the ID of the specific checklist item), passing in a value set to whatever we determined above.  This updates our checklist item to match the state of the card itself.  Then we increment the counter of children by one.

} else {
  // CARD HAS BEEN DELETED, REMOVE THE CHECKITEM
  $trello->request('DELETE', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id));
}

If the card’s ID wasn’t found, we know the card has been deleted.  In that case we fire a DELETE request to /1/cards/cccccc/checklist/bbbbbb/checkItem/aaaaaa to completely remove the related checklist item.

} else {
  // CARD HASN'T BEEN CREATED YET, DO IT IF WE HAVE A LIST TO POST TO
  if ($GLOBALS['config']['board'][$child_board_name]['ns_list']) {
    $name = $card_tag . $item->name;
    $desc = 'Parent Card: ' . $card->shortUrl;
    $new_card = $trello->request('POST', '/1/cards', array('name' => $name, 'idList' => $GLOBALS['config']['board'][$child_board_name]['ns_list'], 'desc' => $desc));

    if ($new_card->id) {
      // UPDATE THE CHECKLIST ITEM WITH THE LINK TO THE NEW CARD
      $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/name'), array('value' => $new_card->shortUrl));
      $trello->request('PUT', ('/1/cards/' . $card->id . '/checklist/' . $checklist_data->id . '/checkItem/' . $item->id . '/state'), array('value' => 'incomplete'));
    }

    $children++;
  }
}

Now we’re back to if we didn’t find a URL in the checklist item’s name.  In that case, we know the child card hasn’t been created yet so we go on to do that, assuming we actually have a list ID to work with (just in case there’s no label on the parent card or something).

We build the child card’s name by combining the text of the list item with the card tag we defined earlier, then we build the description by just linking to the parent card.  Then we fire that data (along with the list ID) off via a POST request to /1/cards.

That request returns data about the newly-created card.  If the card was successfully created (determined by whether or not we got an ID back), we go back to the checklist item.  We update the name to be a link to the new card, which displays in Trello as that card’s name, and we make sure to set the status to incomplete just in case someone did something stupid.  That’s done with a pair of PUT requests, one to /1/cards/cccccc/checklist/bbbbbb/checkItem/aaaaaa/name with value set to the new card’s shortUrl, the other to /1/cards/cccccc/checklist/bbbbbb/checkItem/aaaaaa/state with value set to incomplete.

At this point we’ve looped through every item on the “Slices” checklist for a single card.

if (($children > 0) AND ($children == $children_completed)) {
  $trello->request('PUT', ('/1/cards/' . $card->id . '/'), array('pos' => 'top', 'idList' => $GLOBALS['config']['board']['wip_prototype']['completed_list']));
}

The last thing we do before moving on to the next card is compare our counts of children and completed children. To account for the fact that a card may have been moved to Requests in Progress before the “Slices” checklist was completed, we require at least one child to exist.  If there are children and they’ve all been completed, we move the parent card over to the Published column of our WIP board with a PUT request to /1/cards/dddddd, where dddddd is the parent card ID, with the pos defined as top and the idList defined as the ID of the published column.

As I mentioned in the video, I’ve got this running once a minute as a cronjob.  I’m sure there’s a better way to do it but this is my first pass so I’m not worried about it.

CivClicker Type Availability User Script

Awhile ago I wrote about how I’d gotten addicted to the Cookie Clicker game to the point that I made a userscript that would display a countdown showing how long it would take for me to get to a certain amount of cookies.

The same people that pullled me into that game tried to get me into a new one, CivClicker.  I think I’ve managed to avoid getting pulled in too far but not before noticing an issue and writing a user script to handle it, which I figured I’d share.

In the game there are several classes of job you can assign your people to.  Six of them require there be enough space in a class of building to hold them.  Ten soldiers for each barracks built, for example.  The problem is that nothing shows how much space you have available, so you might intend to assign a bunch of soldiers only to be unable to and not realize why.  My script provides that information.

As with the Cookie Clicker one, this user script is available for download.  I’ll break it down here, though.

One caveat: There are classes that require not only space in buildings but other resources be available to assign people to them.  I didn’t take that into consideration, this is just about space.

// ==UserScript==
// @name          CivClicker Type Availability Display
// @version       1.0.0
// @namespace     http://www.clarkrasmussen.com/
// @description   See how many of a specific type of character you have the resources to make.
// @include       http://dhmholley.co.uk/*
//
// ==/UserScript==

const VERSION = "1.0.0";

window.addEventListener('load', function() {
	exec(function () {

Just like the Cookie Clicker script, we start with some basic stuff. On page load we run exec() which accepts a function and is defined below.

// write out our elements
var use_rows = ['tannergroup', 'blacksmithgroup', 'apothecarygroup', 'clericgroup', 'soldiergroup', 'cavalrygroup'];
for (var n = 0; row = n < document.getElementById('jobs').getElementsByTagName('tr').length; n++) {
	var row_id = document.getElementById('jobs').getElementsByTagName('tr')[n].getAttribute('id');
	if (use_rows.indexOf(row_id) > -1) {
		document.getElementById('jobs').getElementsByTagName('tr')[n].getElementsByTagName('td')[6].innerHTML += '&nbsp;(<span id="' + row_id.replace('group', 'Available') + '">0</span>)';
	}
}

In CivClicker there is a table of all of the job types. Each row has an ID matching the naming convention of <type>group. We create an array of the six types we care about, then we loop through the table rows. On the ones that match a name we’re looking for, we add a span to the sixth field (the one that shows how many you already have of that particular type). Through some string manipulation, we give that span the ID of <type>Available so we can reference it later.

// do the check every second
setInterval(function () {
	update_availability();
}, 1000);

We set the update_availability() function to run every second.  This means there can be some lag but I didn’t think this needed to run more often.

function update_availability () {
	document.getElementById('tannerAvailable').innerHTML = prettify(tannery.total - (population.tanners + population.tannersIll));
	document.getElementById('blacksmithAvailable').innerHTML = prettify(smithy.total - (population.blacksmiths + population.blacksmithsIll));
	document.getElementById('apothecaryAvailable').innerHTML = prettify(apothecary.total - (population.apothecaries + population.apothecariesIll));
	document.getElementById('clericAvailable').innerHTML = prettify(temple.total - (population.clerics + population.clericsIll));
	document.getElementById('soldierAvailable').innerHTML = prettify((barracks.total * 10) - (population.soldiers + population.soldiersIll + population.soldiersParty));
	document.getElementById('cavalryAvailable').innerHTML = prettify((stable.total * 10) - (population.cavalry + population.cavalryIll + population.cavalryParty));
}

We see that update_availability() is what actually finds the number of each class that you can have, then subtracts what you already have and displays that in the spans we established earlier.  I’d like to be able to get this down a little bit, we should be able to loop through each class, but since the game itself doesn’t have a list of all the classes available I didn’t bother to expand on that either.  The prettify() function is from the game, however, as I figured I should display numbers the same way they do.

	});
});

function exec (fn) {
	var script = document.createElement('script');
	script.setAttribute('type', 'application/javascript');
	script.textContent = '(' + fn + ')();';
	document.body.appendChild(script);
	document.body.removeChild(script);
}

Last we wrap things up and define the exec() function, which allows us to do this all via script injection, giving access to the game-defined variables.

It’s hardly revolutionary but is another thing I wanted to throw out there.  For the record, your screen ends up looking like this (when you have the font size turned down):

civclicker