Context Sensitive help in Zend Framework

[This post is an extended answer for a question on StackOverflow.com. I'll rework this into a proper multi-page step by step tutorial when I get the time]

A recent project needed an online manual with context sensitive help. A wiki seemed the easiest way to quickly build the manual content. Dynamically linking to wiki pages based on the controller and action makes it very fast to offer help in most places it is needed.Where more specific help is required, and additional view helper allows you to link to any wiki page.

The Wiki

The wiki is made of a few moving parts: a MySQL database table, a model, controller + views and a few helpers. The Wiki controller is called “manual” (short for “online manual”) because my users didn’t necessarily know what a wiki was.

[sql]
CREATE TABLE `manual` (
`id` mediumint(9) NOT NULL AUTO_INCREMENT COMMENT ‘Unique manual identifier’,
`title` varchar(40) NOT NULL DEFAULT ” COMMENT ‘Short title of this page’,
`content` text NOT NULL COMMENT ‘Content of the page. (wiki format)’,
`user_id` mediumint(9) NOT NULL DEFAULT ’0′ COMMENT ‘The related user’,
`last_modified` datetime NOT NULL DEFAULT ’0000-00-00 00:00:00′ COMMENT ‘Date and time this record was last changed’,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `title` (`title`),
KEY `last_modified` (`last_modified`)
) ENGINE=MyISAM
[/sql]

The model:

[php]
class Manual extends Zend_Db_Table
{
private $sqlPrefix = ‘select title, last_modified, user_id from ‘;
const WIKILINK_REGEXP = ‘/(\s+)\[([^\]\n]+)\]([^\(])/ms’;
const MAX_RECENT_CHANGES = 30;

function getPage( $title )
{
$page = $this->fetchRow( ‘title = ‘ . $this->quote( $title ) );
if (empty( $page )) return null;
return $page->toArray();
}

function getPageNames()
{
// array of unique page names in the wiki
return $this->getDefaultAdapter()->fetchCol( ‘select title from ‘ . $this->_name . ‘ order by title’ );
}

function getRecentChanges( $items )
{
if ($items < 1) $items = self::MAX_RECENT_CHANGES;
return $this->getDefaultAdapter()->fetchAll( $this->sqlPrefix . $this->_name . ‘ order by last_modified desc limit ‘ . $items );
}

function getPageIndex()
{
return $this->getDefaultAdapter()->fetchAll( $this->sqlPrefix . $this->_name . ‘ order by title’ );
}

function savePage( $title, $content, $userID )
{
$data = array( ‘content’ => $content, ‘user_id’ => $userID, ‘last_modified’ => date( ‘Y-m-d H:i:s’ ) );
if ($this->update( $data, ‘title = ‘ . $this->quote( $title )) > 0) return;
$data['title'] = $title;
$this->insert( $data );
}

function getBackReferences( $title )
{
// returns a list of all pages that refer to $title
return $this->getDefaultAdapter()->fetchAll( $this->sqlPrefix . $this->_name . ‘ where content like ‘ . $this->quote( ‘%’ . $title . ‘%’ ) . ‘ order by title’ );
}

function deletePage( $title )
{
$this->delete( ‘title = ‘ . $this->quote( $title ) );
}

function buildIndex()
{
// rebuild the search index: you could do this in a cron job
$index = Zend_Search_Lucene::create( WIKI_INDEX_CACHE_PATH );

foreach ($this->fetchAll() as $page)
{
$doc = new Zend_Search_Lucene_Document();
$doc->addField(Zend_Search_Lucene_Field::UnStored( ‘content’, preg_replace( ‘/([a-z])([A-Z])/’, ‘\1 \2′, $page->content ) ));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed( ‘user_id’, $page->user_id ));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed( ‘last_modified’, $page->last_modified ));
$doc->addField(Zend_Search_Lucene_Field::Text( ‘title’, $page->title ));
$index->addDocument($doc);
}
}

function search( $text )
{
$result = array();
$index = Zend_Search_Lucene::open( WIKI_INDEX_CACHE_PATH );
$hits = $index->find( $text );
foreach ($hits as $hit)
{
$result[]= array( ‘title’ => $hit->title, ‘last_modified’ => $hit->last_modified, ‘user_id’ => $hit->user_id );
}
return $result;
}

function getMissingPages()
{
$existing = array_map( ‘strtolower’, $this->getPageNames() );
$missing = array();
$stmt = $this->getDefaultAdapter()->query( ‘select title, last_modified, user_id, content from ‘ . $this->_name );
while ($row = $stmt->fetch())
{
preg_match_all( self::WIKILINK_REGEXP, $row['content'], $matches );
$links = array_map( ‘strtolower’, array_unique( $matches[2] ) );
foreach (array_diff( $links, $existing ) as $page)
{
$missing[$page][]= $row['title'];
}
}
return $missing;
}
}
[/php]

The controller displays the Manual pages, provides useful functionality like an alphabetic index, list of recent changes, list of pages linking to a given page, search, plus the usual create / edit / delete functionality.

[php]
require_once MODEL_PATH . ‘Manual.php’;

define( ‘HOME_PAGE_TITLE’, ‘Contents’ );
class ManualController extends Zend_Controller_Action
{
public function indexAction()
{
$pageName = $this->getParameter( ‘WikiPage’, ‘page’, HOME_PAGE_TITLE );

// check for built in actions
if ($action = $this->isBuiltInAction( $pageName ))
{
$this->_forward( $action );
return;
}

$manual = new Manual;
$page = $manual->getPage( $pageName );
if (empty( $page ))
{
// if the page does not exist, create it
$this->_forward( ‘edit’ );
return;
// or you could prevent editing by doing this
// $page = array( ‘title’ => $pageName, ‘content’ => ‘Page does not exist’, ‘last_modified’ => ”, ‘user_id’ => 0 );
}
$this->view->page = $page;
}

function isBuiltInAction( $pageName )
{
$pageName = strtolower( $pageName );
$actions = array( ‘page index’ => ‘pageIndex’, ‘recent changes’ => ‘recentChanges’, ‘missing pages’ => ‘missingPages’ );
if (isset( $actions[ $pageName ] )) return $actions[ $pageName ];
return false;
}

function showTable( $title, $data )
{
$this->view->pages = $data;
$this->view->pageTitle = $title;
$this->render( ‘showTable’ );
}

function recentChangesAction()
{
$manual = new Manual;
$this->showTable( ‘Recent Changes’, $manual->getRecentChanges( Manual::MAX_RECENT_CHANGES ) );
}

function pageIndexAction()
{
$manual = new Manual;
$this->view->pages = $manual->getPageIndex();
$this->render( ‘pageIndex’ );
}

function backReferencesAction()
{
$pageName = $this->getParameter( ‘WikiPage’, ‘page’, HOME_PAGE_TITLE );
$manual = new Manual;
$this->showTable( ‘Back References’, $manual->getBackReferences( $pageName ) );
}

function missingPagesAction()
{
$manual = new Manual;
$this->view->missing = $manual->getMissingPages();
}

function searchAction()
{
$text = trim( $this->getRequest()->getParam( ‘searchText’, ” ) );
if ($text == ”)
{
$this->_forward( ‘pageIndex’ );
return;
}
$manual = new Manual;
$this->showTable( ‘Search Result’, $manual->search( $text ) );
}

function editAction()
{
$pageName = $this->getParameter( ‘WikiPage’, ‘page’, HOME_PAGE_TITLE );
if ($pageName == ”) $pageName = HOME_PAGE_TITLE;
$manual = new Manual;
$page = $manual->getPage( $pageName );
if (empty( $page )) $page = array( ‘title’ => $pageName, ‘content’ => ” );
$this->view->page = $page;
}

function saveAction()
{
$pageName = $this->getParameter( ‘WikiPage’, ‘page’ );
if (!empty( $pageName ))
{
$manual = new Manual;
$manual->savePage( $pageName, $this->getRequest()->getParam( ‘content’ ), $this->getCurrentUserID() );
// $this->infoMessage( ‘Saved OK’ );
}
$this->_forward( ‘index’ );
}

function deleteAction()
{
$pageName = $this->getParameter( ‘WikiPage’, ‘page’ );
if (!empty( $pageName ))
{
$manual = new Manual;
$manual->deletePage( $pageName );
$this->getRequest()->setParam( ‘page’, HOME_PAGE_TITLE );
}
$this->_forward( ‘index’ );
}
}
[/php]

The views are all trivial except for two

index.phtml:
[php]

$url = $this->view->url( array( ‘controller’ => ‘manual’, ‘action’ => ‘index’, ‘page’ => ” ) );
$text = $this->page['content'];
$text = preg_replace_callback( ‘/\[file:([^\] ]*)\]/ms’, array( $this, ‘viewFile’ ), $text );
$text = preg_replace( Manual::WIKILINK_REGEXP, "$1<a href=\"$url$2\">$2</a>$3", $text );
print Markdown( $text );

[/php]
where Markdown is the very handy PHP Markdown library. Note that we override the Markdown auto linking scheme: wiki page links are surrounded by square braces eg [Help Index] a la wikipedia.

The other useful view is pageIndex.phtml, which shows an alphabetic index of pages:
[php]
// A B C D at top
print ‘<p>’;
foreach (range( ‘A’, ‘Z’ ) as $letter )
{
print ‘<a href="#’ . $letter . ‘">’ . $letter . ‘</a> ‘;
}
print ‘</p>’;

$lastLetter = ”;
print ‘<table>’;
foreach ($this->pages as $page)
{
$letter = strtoupper( substr( $page['title'], 0, 1 ) );
if ($lastLetter != $letter)
{
print ‘<tr><td bgcolor="#FFFFCC"><b><a name="’ . $letter . ‘"></a>’ . $letter . ‘</b></td></tr>’;
$lastLetter = $letter;
}
print ‘<tr><td>’ . $page['title'] . ‘</td></tr>’;
}
print ‘</table>’;
[/php]

The Helper

In the layout script, I added a menu item “Help” which was rendered via this helper which builds the wiki link from the words [Help ControllerName ActionName]. So for the customer/edit action the help page would be ‘Help Customer Edit’.

[php]
class Zend_View_Helper_ContextHelp extends Zend_View_Helper_Abstract
{
function contextHelp( $title = ‘Help’ )
{
$controller = Zend_Controller_Front::getInstance();
$request = $controller-&gt;getRequest();
$page = ‘Help ‘ . ucfirst( $request-&gt;getControllerName() );
if ($request-&gt;getActionName() != ‘index’) $page .= ‘ ‘ . ucfirst( $request-&gt;getActionName() );
return $this-&gt;view-&gt;url( array( ‘controller’ =&gt; ‘manual’, ‘action’ =&gt; ‘index’, ‘page’ =&gt; $page ), null, true );
}
}
[/php]

There was a whole lot more additional related documentation stored in the wiki, so context Help pages started with the prefix ‘Help ‘ in the title. This was useful because it allowed help pages to refer to other business process documentation and to save me repeating myself in several places (grin).

Another helper made it easy to link to the Manual from other random places throughout the application. I did this frequently for form fields: adding the help link in the field description to advise on correct content with examples, some with links to other wiki pages for more detail or extended examples.

[php]
class Zend_View_Helper_ManualLink extends Zend_View_Helper
{
// return a link to the manual
function manualLink( $page = ‘Help Manual’ )
{
return $this->view->url( array( ‘controller’ => ‘manual’, ‘action’ => ‘index’, ‘page’ => $page ), null, true );
}
}
[/php]

Tags:

Comments (1)

Writing for the Web

Writing for the web is very different to writing for print. Most visitors to your site have tired eyes. They will have come in via Google and will give your page about 10 seconds (if you are lucky) while they skim for keywords related to topics they are interested in. If you are lucky, they won’t press the Back button.

Read the first 2 or 3 articles at http://www.useit.com/papers/webwriting [by Jacob Neilson, the web usability guru].

My top tips for writing are

  • Use short sentences and paragraphs
  • Use small words
  • Prefer bulleted lists rather than long comma separated phrases
  • Write your first draft. Count the words. Aim to reduce the word count by 1/2
  • Create a clear document structure (mind maps help)
  • Make meaningful link text: never use the phrase “click here”
  • Relevant pictures and illustrations help a lot (and can save 1000 words!)
  • Use plain formatting: just the standard html heading and paragraph tags. Extra formatting distracts.
  • Say it once and only once! Some people suggest the inverted pyramid style of writing used by newspapers. I think it takes too long to read and you get sick of the repetition.

Tags:

Comments Off

Bible Radio

Bible Radio is a ministry of Rhema Broadcasting Group. It is a 24/7 radio and web streaming broadcast of the spoken Bible from Genesis to Revelation. You can listen to the stream at www.bibleradio.co.nz.

Bible Radio is not currently on air nationwide. You can hear it in the Waikato on 576AM, Invercargill on 1026AM, Mosgiel 88.6FM and Dunedin 107.3FM. The first two frequencies are normal full power licensed radio stations. The last two are “Low Power FM”, which are lower power broadcasts covering a smaller area and not subject to the usual licensing restrictions.

Palmerston North does not currently have access to Bible Radio. But it is surprisingly easy to make it available. Three things are needed: some transmission equipment, a spare frequency in the Low Power FM band, and a site for the transmitter.

The Manawatu Low Power FM (LPFM) convener has suggested that the 103.7FM frequency may be available for us to use. The equipment will cost approximately $2500. I have raised $1000 already and will seek further funding from the community once we have a site available.

Current needs for the project are

  • A site for the transmitter. The higher the better. Low Power FM is line of sight, with an approximate radius of 5km. This is a useful size as with the right location we can cover a significant percentage of the city. Running costs should be minimal: the equivalent of leaving a couple of PCs running.
  • Another $1500 to pay for the equipment

Tags:

Comments Off

e100

The e100 Bible Reading Challenge [www.e100nz.org.nz] picks the top 100 “must read” sections of scripture and organises them into logical sequence. There is a nationwide release in April 2010, though promotion started late 2009 with national roadshows, a web site and advertising material.

Thanks to Stephen Opie of the Bible Society, we have recently released an iPhone app containing

  • The full text of the e100 bible readings in several versions (WEB, ASV, KJV with more coming soon)
  • The full text of “The Essential 100″ book: a commentary / devotional guide through the 100 verses
  • Tools to track your progress through the material.

Thanks to Apple for approving the app in a record 5 days for v1.0 and 3 days for v1.1 (we started early after rumours of a 3 month backlog)

Buy the App

Photos / screen shots below.

Tags:

Comments Off