Thursday 31 March 2011

Being too clever with forms

The situation I ran into was with a content type called "document", it's pretty simple: title, description, taxonomy term and a single file upload.

The tricky bit was the taxonomy: each document could belong to a different part of the site (represented by the top level of the taxonomy) let's say SectionA and SectionB, within each of those there'd be subsections: SubA1, SubA2... and SubB1, SubB2 etc.

The first level was selected by the place the document was created - so someone might be in Section A, they create a document - I add the taxonomy term to the URL, like this: node/add/document/X and, in a hook_form_alter(), I use that added value to modify the allowed options in the taxonomy selector to only include the subsections for the section we came from.

Which is fine.

Except when you factor in the file upload. What a file upload does is rebuild the form - which meant my code was getting called again and this was bad. The real code was a bit more complex than I've described here and reloading was very bad - basically it completely wiped out the taxonomy options on the second run through. Which then meant I got validation errors.

The solution is that you have to have some way of noting that you've been here before and not do the changes again. One thing know is that $form_state does not get rewritten so you can store a variable in there marking that you've done the changes already, it should look something like this (using hook_form_FORMID_alter()):

function mymodule_form_myform_alter(&$form, &$form_state) {
  if (isset($form_state['mymodule_myform_processed'])) {
    return;
  }
  $form_state['mymodule_myform_processed'] = TRUE;


  ...the rest of the code...
}

And that does the trick.

Wednesday 30 March 2011

Using stream wrappers

Sorry I haven't uploaded my two new modules to d.o yet - just not had time.

In my current contract the client wanted the option of either downloading a PDF or opening it in the browser (assuming the browser could handle it - but that's not my problem). These PDFs being stored in the Private file space.

The first question they had was: can it be done? They'd been told it couldn't. But I assured them with naive certainty that it probably could. I had already sorted out the ability to auto-create and download archives and I was pretty sure it was merely a matter of the correct HTTP headers.

But how to do it? What I needed was a different path to the same file, one path would download it, the other path would display it in the browser.

If you're using Private files the URL you get is 'system/files/filename.pdf' which goes through various checks to ensure the download is permitted, and gets the HTTP headers at the same time, using hook_file_download(). The path fragment "system/files" is what invokes the file_download() function. If you create a different stream wrapper, such as my archive stream, this is replaced with "system/archive" - or whatever you want really.

So I thought, okay, how about I create a new stream wrapper based on a "system/views" path which invokes file_download() but extracts the Content-Disposition header before sending it.

The trick here is to make the new stream wrapper point to the same location as the Private stream - so it can access the same files - and then, when I'm building the download link, I get the file's URL (e.g. "system/files/filename.pdf") and simply do a string replace, changing "files" to "views".

So the user clicks on my modified URL, it goes through my routines which get the headers but strip out the Content-Disposition header and, as a result, instead of downloading the file, it opens in the browser. I have two links to the same file, each one does something different with the file.

In retrospect using the word "Views" may be a little confusing, obviously it's nothing to do with the Views module - sorry about that.

You need the stream wrapper class...


class ViewsStreamWrapper extends DrupalLocalStreamWrapper {
  /**
   * Implements abstract public function getDirectoryPath()
   *
   * This is identical to the private stream wrapper which means
   * we can just replace 'files' with 'views' the path and get the same file
   */
  public function getDirectoryPath() {
    return variable_get('file_private_path', '');
  }


  /**
   * Overrides getExternalUrl().
   */
  public function getExternalUrl() {
    $path = str_replace('\\', '/', $this->getTarget());
    return url('system/views/' . $path, array('absolute' => TRUE));
  }
}

You need to tell Drupal about the class...


/**
 * Implements hook_stream_wrappers().
 */
function views_stream_stream_wrappers() {
  return array(
    'views' => array(
      'name' => t('Views'),
      'class' => 'ViewsStreamWrapper',
      'description' => t('Stream wrapper for viewing files in a browser'),
      'type' => STREAM_WRAPPERS_READ,
    ),
  );
}



And then the hook_menu() and hook_file_download() support, note this code assumes a PDF.


/**
 * Implements hook_menu().
 */
function views_stream_menu() {
  $items = array();


  $items['system/views'] = array(
    'title' => 'View files',
    'page callback' => 'file_download',
    'page arguments' => array('views'),
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );


  return $items;
}


/**
 * Implements hook_file_download().
 *
 * Construct the headers to ensure the file gets downloaded
 */
function views_stream_file_download($uri) {
  list($scheme, $path) = explode('://', $uri, 2);
  if ($scheme=='views') {
    $file = (object) array(
      'filename' => basename($path),
      'filemime' => 'application/pdf',
      'filesize' => filesize(drupal_realpath($uri)),
    );
    $headers = file_get_content_headers($file);
    unset($headers['Content-Disposition']);
    return $headers;
  }
}


And then as an example of modifying a private file. The $uri variable contains the file's internal path which you can get from a file entity, or file field.


$href = file_create_url($uri);
$href = str_replace('/files/', '/views/', $href);


$link = array(
  '#type' => 'link',
  '#title' => t('View'),
  '#href' => $href,
  '#attributes' => array('target' => '_blank'),
  '#weight' => 0,
);


And that's all there is to it.

Monday 21 March 2011

Coming to a website near you

Now I don't want you to get too excited but I have recently completed two little Drupal 7 modules designed to make my life easier - so may well help other developers:

field_extract
This takes the little function I built to extract values from fields to the extreme by attaching "extractors" to the cached field type data, and then using the appropriate extractor to extract the data from a field. So there's one extractor that gets nodes, another that gets terms, another for fields, for body, for text, for integers and so on. It follows proper Drupal guidelines and is completely extensible.

archive_stream
Makes it easy to build a downloadable Zip archive using stream_wrappers. You give it a temporary filename and an array of file info arrays (or file entities) and it gives you a file path you can use for downloading. The module also handles the downloading part when a user accesses the link.

Simples.

It may take a few days to get them on to drupal.org but I'll let you know.

Thursday 17 March 2011

Getting field data out of entities

EDIT: download the module that encapsulates this behaviour and does a lot more besides: read this blog.

The new field structure in Drupal 7 is very clever but also quite irritating when you want to get data out of a field in an entity, so I have written this handy routine which extracts field data:


/**
 * Returns field values as actual entities where possible,
 * also allows selection of individual items to be returned
 */
function field_fetch_field_values($entity_type, $entity, $field_name, $get_delta = NULL, $get_key = NULL) {
  $values = array();
  if (isset($entity->$field_name) && !empty($entity->$field_name)) {
    foreach (field_get_items($entity_type, $entity, $field_name) as $delta => $item) {
      $value = $item;
      $keys = array_keys($item);
      if (count($keys)==1) {
        $key = $keys[0];
        switch ($key) {
          case 'nid':
            $value = array_shift(entity_load('node', array($item[$key])));
            break;
          case 'uid':
            $value = array_shift(entity_load('user', array($item[$key])));
            break;
          case 'tid':
            $value = array_shift(entity_load('taxonomy_term', array($item[$key])));
            break;
          case 'vid':
            $value = array_shift(entity_load('taxonomy_vocabulary', array($item[$key])));
            break;
          case 'value':
            $value = $item['value'];
            break;
        }
      }
      else {
        if ($get_key && isset($item[$get_key])) {
          $value = $item[$get_key];
        }
        elseif (array_key_exists('value', $item)) {
          $value = isset($item['safe_value']) ? $item['safe_value'] : $item['value'];
        }
      }
      $values[$delta] = $value;
    }
  }
  if (is_numeric($get_delta)) {
    return isset($values[$get_delta]) ? $values[$get_delta] : NULL;
  }
  return $values;
}


Use it like this:

$terms = field_fetch_field_values('node', $node, 'field_myterms');

Extracts all the tids from the field and returns the actual terms, not the tids.

$term = field_fetch_field_values('node', $node, 'field_myterms', 0);

Returns just the first term.

$summary = field_fetch_field_values('node', $node, 'body', 0, 'safe_summary');


Returns only the summary from the body field, but does not create a summary if there isn't one.


$info = field_fetch_field_values('node', $node, 'field_myfiles', 0);


If field_myfiles contains uploaded file data you get the full file info array back, in this example just the first one.


There are probably cleverer ways of doing this (and you could do multiple entity_loads to make it more efficient) but I find this works well enough for normal day-to-day use.

Tuesday 8 March 2011

Using Views custom text

So here's the thing: for the current project I had to produce an administrative list of node types, meetings in this case, and alongside have a bunch of links to edit, delete and one for other management stuff.

Views is your friend. Creating a table output of a list of nodes ordered by meeting date is easy-peasy but the challenge here is adding links.

It crossed my mind that I could intercept Views output in half a dozen places, programmatically or by creating replacement templates but that sounds far too much like hard work. I was sure that I'd seen an option to add custom text to a View somewhere, and I knew from a project last year sometime that Views very kindly allows you to use tokens built from fields being displayed in the row it's building.

And so it turns out ... with some caveats:

I had configured the view to output three columns - Name, Date/Time and Location - now I needed a column with the links.

So I added a field, selected "Global" and chose Custom text.

Then added the text: <a href="node/[nid]/edit">Edit</a>

I'll keep it simple and just use one link.

Now this doesn't work, why? Because the tokens can only be for fields that have been used and appear <em>before</em> the field you need them. So I had to add a "Node: Nid" field and put it at the top of the list - remembering to check the "Exclude from display" box because I don't want a column of nids in the table.

And now - I get garbage in the link. The nid numbers are going in okay, but the HTML is all screwed up.

This is the caveat: I was pretty sure, with Drupal 6, that you get the raw output of the field. So if I ask for the nid all I get is the nid (could be wrong on that, I'm not going to check). But it turns out that with Drupal 7 Views what you get in the token is the processed output of the Node: Nid field.

I recommend unchecking every option, but especially uncheck the "Link this field to its node" box, because that's what I was getting, where I have [nid] in the URL it was embedding a complete link.

Having said that, once you take out all the extra options you just get the raw nid. And the link now works.