Archive for December, 2008

Handling custom file attachments in WordPress plugins

I’ve been working on a wordpress plugin to make it easy to set a banner image for any blog entry outside the post content. The current approach is flawed as it requires hacking at the form tag for the post edit page to allow file uploads, but while I look around for a better way to do that without sacrificing the simplicity of the UI, I wanted to share some digging I did to figure out how to handle file uploads with wordpress. All this data is available in the documentation, but it took quite a bit of looking to find it and there aren’t enough examples kicking around.

So without further ado…

When wordpress receives a file upload it hands it to the function wp_handle_upload. This takes two arguments:

/*
 * @param array $file Reference to a single element of $_FILES. Call the function once for each uploaded file.
 * @param array $overrides Optional. An associative array of names=>values to override default variables with extract( $overrides, EXTR_OVERWRITE ).
 */

and returns:

/*
 * @return array On success, returns an associative array of file attributes. On failure, returns $overrides['upload_error_handler'](&$file, $message ) or array( 'error'=>$message ).
 */

In practice that means a successful run will give you something like:

array (
  'file' => '/path/to/webroot/wp-content/uploads/myfile.png',
  'url' => 'http://my.host/wp-content/uploads/myfile.png',
  'type' => 'image/png',
)

It can be found in wp-admin/includes/file.php.

A simplified example of how I’m using it in my wordpress-post-banners plugin follows:

/* Define the callback that will be run when a post is saved */
add_action('save_post', 'post_banners_process_saved_post');
 
/**
 * Process any new file that has been uploaded for this post
 *
 * If a new file has been uploaded, we hand it to wordpress to process
 * and then store the details in a custom field.
 *
 * @param integer $post_id may be the actual ID, may be a revision ID
 * @return null
 */
function post_banners_process_saved_post($post_id) {
  /* If the file we want has been uploaded */
  if (isset($_FILES['post_banner_image'])) {
    $file = wp_handle_upload($_FILES['post_banner_image']);
 
    /* Check if it was an error */
    if (isset($file['error'])) {
      // handle error
    } else {
      // store the image details in a custom field
      global $wpdb;
      $wpdb->query($wpdb->prepare('REPLACE INTO ' . $wpdb->postmeta . ' (`post_id`, `meta_key`, `meta_value` ) VALUES (%s, %s, %s)', 
        $post_ID, 'post_banner_image', $file['url']));
    }
  }
}

I’ll post again once the plugin settles down, but in the meantime hopefully an extra example of how to use wp_handle_upload will help someone.

Using amazon and amazon-ecs to identify genres

I’m currently building a site where users will enter details of books and music they’re listening to and we want to provide lists of that on their profile and also find ways of matching users based on those choices. We’re looking at a number of ways of doing that, including matching based on genre, and in order to achieve that I needed a way to identify the genres for their listed books and music.

I knew that amazon provided that data through their Associate Web Services (formerly E-Commerce services) so I fired up the trusty amazon-ecs gem and pulled down the data for a couple of books and CDs. eg.

res = Amazon::Ecs.item_search(asin, :search_index => 'Music', :country => 'uk', :response_group => 'Large')

Looking at the results I see that amazon assign a varying number of ‘browsenodes’ to each item. browsenodes are a flexible system that allows amazon to associate arbitrary hierarchical data to items in their catalogue. They describe them in the documentation as:

Browse nodes are categories into which items for sale are organized. A single node might have many items associated with it. In the above example, the child node, “Boxed Sets,” might have the items “Abott and Costell Collection,” and “Laurel and Hardy Collection” associated with it.

The number of items associated with a browse node can change radically over time as items are added for sale, or as items go out of stock and are no longer sold. For example, for the browse node, TopSellers, items are attached and unattached according to their sales.

Even browse nodes themselves are created and deleted as items demand. When, for example, a new toy starts selling briskly, there may not be a node that appropriately categorizes the toy. In that case, a node would be created and the toy would be associated with the node. Then, if the sale of the toy died out, the node might be deleted. Other nodes are much longer lived. Top level nodes, for example, “Books” and “Apparel,” have remained unchanged for years.

That means that an album may have browsenodes giving us values of “Pop, Jazz, and Hard Bop”, but the same item may also have browsenodes with names like “Bestsellers”, “40% off sale”, and all sorts of other useless data. Thankfully it’s fairly easy to identify that genre data all sits under “Styles” (for music) and “Subjects” (for books) which have IDs 520920 and 1025612 respectively in the UK.

Once I had those parents identified it was easy enough to extend the Amazon::Element class to add a method to read out the appropriate browsenodes and return them:

class Amazon::Element
  def extract_genre_children_of(browse_node_id)
    browse_nodes = elem.search('browsenodes>browsenode')
    genres = browse_nodes.inject([]) do |collection, bnode|
      if bnode.search("//ancestors//browsenode/browsenodeid[text()='#{browse_node_id}']").length > 0
        collection += bnode.search('name').collect(&:inner_text)
      end
      collection
    end
    genres.uniq
  end
end

That code’s far from perfect – it returns a few higher level items I subsequently filter out – but it’s doing the job for me.

Of course, the resulting data is variable in quality. I certainly wouldn’t call Battles’ Mirrored “Adult Contemporary”, but it’s a start towards finding some genres automatically and hopefully as we build up a large enough store of data it will begin to become more useful. In the meantime, we’re also experimenting with musicbrainz to pull in some extra data from their tags.

For Sale: Canon Digital Rebel XTi/400D

UPDATE: The camera has now been sold

rebel_xti_586x225

Having recently upgraded to the wonderful Canon EOS 50D I’m looking for a new home for my old 400D (actually the US version: the Digital Rebel XTi). I’ve taken about 15,000 photos with it and it was a great introduction to the world of digital SLRs. Despite all that use, it’s in really good shape and will come complete with a 2GB memory card, a spare (non-Canon, but perfectly fine) battery, strap, 18-55mm kit lens, and US and UK chargers, all in the original box.

I’m planning to list the camera on ebay on Monday or Tuesday next week, but wanted to throw the option out there if anyone wants to make me offers in the region of £275 including postage (within the UK). Email me at james@jystewart.net if you’re interested.

XML_Feed_Parser: Handing over the reins

For the past few years I’ve been maintaining a PHP package called XML_Feed_Parser. It’s part of PEAR and attempts to offer a unified API for handling RSS and Atom feeds in your PHP code, a little inspired by projects like the universal feed parser. Its parsing and API are pretty comprehensive, but lately I’ve been falling a bit behind in managing it and there are aspects that could definitely do with some attention.

So I’m looking to hand it all over to someone with more time and energy for it than I. Preferably someone who uses it in an active project (being primarily a ruby developer these days, I spend a lot more time with feedtools than with my own package). I’m going to mark the package as ‘unmaintained’ and if you want to take it on, take a look at the appropriate page in the manual.

And if you want the full story of why I’ve chosen now to make this move, it’s made fairly clear on flickr and my other blog.