[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]