Making conversation with Drupal

CoreDogs lessons are not just straight text. Among other things, lessons include conversations. The conversations are between three characters:

  • Kieran – a teacher and Web expert.
  • CC – a learner with business experience but limited tech background.
  • Renata – a young, enthusiastic learner with little business experience.

The characters model good learning. CC and Renata interrupt Kieran to ask challenging questions. Some questions are about the content, asking for more explanation. Some questions are about why a topic is important. Sometimes, CC and Renata express misgivings about themselves, such are whether they are smart enough to learn programming. This gives me the opportunity to write about things like persistence, and social support.

Here’s a sample conversation.

Kieran
Kieran

Hi! I’m Kieran.

CC
CC

I’m CC.

Kieran
Kieran

Kieran again, and I’m happy to see you!

Renata
Renata

I’m Renata. Aren’t I cute?

I’m young, and full of beans. Hmm, strange expression. Why not “full of corn?” Or “full of coffee?”

CC
CC

Hey, I’m cute, too! Like my bow?

You can real conversations on the xWorlds page, among others.

This article explains how I implement the conversations in Drupal. Let’s start by talking about what I wanted to achieve.

Goals

I had goals for both workflow and format.

Workflow refers to the process I use to create conversations. If you write, you know that you get into a groove, where the text keeps flowing. I wanted to add conversations without interrupting that flow.

What about the format? Some goals:

  • Conversations should look the same across lessons.
  • Conversations should stand out from normal lesson text.
  • Each conversation is an exchange. I wanted it to be clear when one character stopped speaking and another one started.
  • I wanted a photo with each statement. The photos could express different emotions.

A new content type?

When you create content in Drupal, each content chunk usually becomes a different node. Each node has a content type, like “story” or “blog post.”

This suggests that I should create a content type for conversations. Each conversation would its own a node. Conversation nodes would be inserted into lesson nodes as required.

I decided that the workflow would be smoother if I used a different approach.

Input filter

I created a custom module with a input filter. Writers add conversation tags to lesson text, and the filter would format them into a conversation. Conversations don’t exist separately from lesson nodes. Instead, they’re just regular text within their nodes, with tags that apply special formatting.

Creating modules specific to a project is common practice. Many projects have one or two unusual requirements. It’s unlikely that you will include conversations on your Drupal site, and certainly not between Kieran, CC, and Renata.

Here’s what I typed to create the conversation above.

+++ Start conversation +++

Kieran: Hi! I’m Kieran.

CC: I’m CC.

Kieran 7: Kieran again, and I’m happy to see you!

Renata: I’m Renata. Aren’t I cute?

I’m young, and full of beans. Hmm, strange expression. Why not “full of corn?” Or “full of coffee?”

CC 4: Hey, I’m cute, too! Like my bow?

+++ End conversation +++

The optional number after the character name (e.g., the 7 in “Kieran 7”) says which photo to use. It defaults to 1.

Note that Renata’s statement contains more than one paragraph. That had to be handled somehow, without extra formatting tags.

The module

I created a module called conversation. Here’s the code.

<?php
define('CONVERSATION_DEFAULT_IMAGE_NUMBER', 1);
define('IMAGE_NAME_PATTERN', '_icon%.jpg');
define('CONVERSATION_IMAGE_PATH', 'content_media/main_characters/');

function conversation_init() {
  drupal_add_css(drupal_get_path('module', 'conversation') .'/conversation.css');
}

function conversation_filter($op, $delta=0, $format=-1, $text='') {
  switch($op) {
    case 'list':
      return array(t('Conversation filter'));
    case 'description':
      return t('Format conversation tags.');
    case 'settings':
      break;
    case 'no cache':
      return FALSE;
    case 'prepare':
      return $text;
    case 'process':
      return preg_replace_callback(
'/\<p\>\s*\+{3}\s*start\s*conversation\s*\+{3}\s*\<\/p\>?(.*?)\<p\>\s*\+{3}\s*end\s*conversation\s*\+{3}\s*\<\/p\>?/is',
          'conversation_add_conversation', $text);
    default:
      return $text;
  }
}

/**
 * Format a conversation.
 * @param object $matches Contains one conversation, without delimiters.
 * @return HTML for the conversation. 
 */
function conversation_add_conversation($matches) {
  $text = $matches[1];
  $text = preg_replace_callback(
      '/\<p\>\s*(kieran|cc|renata)(\s*\d*\s*)\:/is',
      'conversation_format_statement', $text);
  //Find the first end div and kill it
  $start = stripos($text, '</div>');
  $text = substr($text, 0, $start) . substr($text, $start + 6);
  //Wrap entire conversation.
  $text = '<div class="conversation">' . $text . '</div></div>';
  return $text;
}

/**
 * Format one statement by a character in a conversation.
 * @param object $matches
 * @return 
 */
function conversation_format_statement($matches) {
  $result = '</div>';
  $character_name = $matches[1];
  $image_number = (int)$matches[2];
  if (! is_numeric($image_number) || $image_number < 1 ) {
    $image_number = CONVERSATION_DEFAULT_IMAGE_NUMBER;
  }
  $image_name = strtolower($character_name) . str_replace(
      '%', $image_number, IMAGE_NAME_PATTERN);
  $result .= '<div class="conversation-turn"><div class="character-icon">'
      .'<img src="'.base_path().CONVERSATION_IMAGE_PATH.$image_name.'" alt="'.$character_name.'">'
      .'<div class="character-name">'.$character_name.'</div></div><p>';
  return $result;
}

Line 7 loads the module’s CSS file into <head>.

hook_filter defines the conversation filter. Line 24 does most of the work. The regex matches a string like this:

  • \<p\> Open p tag.
  • \s* 0 or more characters of white space.
  • \+{3} Three +s.
  • \s* 0 or more characters of white space.
  • The text start.
  • \s* 0 or more characters of white space.
  • The text conversation.
  • \s* 0 or more characters of white space.
  • \+{3} Three +s.
  • \s* 0 or more characters of white space.
  • <\/p\> Close p tag.
  • ?(.*?) The conversation. The () makes the text available to the callback function.
  • \<p\> Open p tag.
  • \s* 0 or more characters of white space.
  • \+{3} Three +s.
  • \s* 0 or more characters of white space.
  • The text end.
  • \s* 0 or more characters of white space.
  • The text conversation.
  • \s* 0 or more characters of white space.
  • \+{3} Three +s.
  • ? Don’t be greedy

Understanding greediness is important. I wanted to be able to insert several conversations in the same lesson. Suppose there were two conversations in a particular lesson, like this:

+++ Start conversation (1) +++
...
+++ End conversation (1) +++
...
+++ Start conversation (2) +++
...
+++ End conversation (2) +++

The regex engine starts matching at line 1. I wanted it to end the match at line 3. But, by default, the regex engine is greedy. It would gobble up everything it could, and end the match at line 7. That would give me one match, starting at line 1 and ending at line 7.

The ? tells the engine not to be greedy. It stops the match at line 3. This gives me two matches, one from lines 1 to 3, and the other from lines 5 to 7. W00f!

There are modifiers at the end of the regex:

  • i Not case sensitive.
  • s Match newlines as regular characters.

Notice that the pattern looks for <p> and </p>. But that isn’t in the conversation tags’ syntax. What’s the deal?

Input filters in Drupal are processed in order. I use the textile filter when writing lessons. It converts text separated by blank lines into paragraphs. So:

+++ Start conversation +++

becomes:

<p>+++ Start conversation +++</p>

When configuring Drupal, I make sure that the textile filter is run before the conversation filter. So the conversation filter’s regex should grab the paragraph tags.

preg_replace_callback() (line 23) calls the function conversation_add_conversation(), passing the text that was matched by the regex.

conversation_add_conversation() is called for each conversation in the lesson. The second element of the array passed to the function contains the text that was matched. Hence line 37. It puts the conversation that was matched into $text.

The regex in line 39 matches statements within a conversation.

  • \<p\> Open p tag.
  • \s* 0 or more characters of white space.
  • (kieran|cc|renata) matches the character making the statement. The () makes the matching text available to the callback.
  • (\s*\d*\s*) matches some optional whitespace, a number, and some more optional whitespace. Again, the () makes the matching text available to the callback.
  • : matches a colon.

Modifiers:

  • i Not case sensitive.
  • s Match newlines as regular characters.

conversation_format_statement() adds the formatting tags. The <div> classes match those defined in the module’s stylesheet.

There’s some extra code that sets containership for the HTML tags correctly. It messes with <p>s and <div>s. This gets a bit tricky when there’s more than one paragraph in a statement.

Conclusion

Webers think about workflow. Good workflow lets writers create higher quality content in a given amount of time. They can focus on what they’re writing, and let the software take care of formatting.

The conversation module lets me add conversations to lessons, without breaking the flow of my writing. The module shows how to put regular expressions to work.

W00f!

The code is attached in a zip file. No warranties, no quality promises, use at your own risk, and other weasel words.

AttachmentSize
conversation.zip2.1 KB

Lessons

How to...


Dogs