syntax_plugin_nstoc.php - A PHP4 class that implements * a DokuWiki plugin to generate a * namespace table of contents. * *

* Usage:
* {{nstoc [namespace [maxdepth]]}} *

 *	Copyright (C) 2006, 2010  M.Watermann, D-10247 Berlin, FRG
 *			All rights reserved
 *		EMail : <support@mwat.de>
 * 
* This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either * version 3 of the * License, or (at your option) any later version.
* This software is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. *
* @author Matthias Watermann * @version $Id: syntax_plugin_nstoc.php,v 1.17 2010/02/21 14:36:27 matthias Exp $ * @since created 23-Dec-2006 */ class syntax_plugin_nstoc extends DokuWiki_Syntax_Plugin { /** * @privatesection */ //@{ /** * Callback function for use by the global search() function. * * @private * @see render() */ var $_callback = NULL; /** * HTML special characters to replace in render(). * *

* This property is used to avoid repeated memory allocations * inside the _doMarkup() loops. *

* @private * @since created 09-Aug-2007 * @see _doMarkup() */ var $_Chars = array('&', '<', '>', '"'); /** * Entity replacements for HTML special characters. * *

* This property is used to avoid repeated memory allocations * inside the _doMarkup() loops. *

* @private * @since created 09-Aug-2007 * @see _doMarkup() */ var $_Ents = array('&', '<', '>', '"'); /** * Lookup table for headlines ./. levels. * * @private * @since 12-Aug-2007 * @see _getHeadings() */ var $_Hlevels = array('======' => 1, '=====' => 2, '====' => 3, '===' => 4, '==' => 5, '=' => 6); /** * Additional markup used with older DokuWiki installations. * * @private * @since created 20-Feb-2007 * @see _fixJS() */ var $_JSmarkup = FALSE; /** * Prepare the (X)HTML markup. * *

* Each entry of the given $aList (indexed by page ID) * is expected to be a list of arrays with the respective entry's level * at index 0 (zero) and the headline's text at index * 1 (one) the latter of which is used to construct the * respective hypertext link fragment identifier. *

* @param $aList Array The list of headlines in $aID. * @return String The list markup to add to the document. * @private * @see render() */ function _doMarkup(&$aList) { $divOpen = array_fill(0, 0xff, 0); //XXX 255 levels as in "handle()" $curLvl = 0; $markup = array(); // buffer to avoid string re-allocations while (list($id, $ul) = each($aList)) { unset($aList[$id]); // free mem $link = '
  • '; ++$divOpen[$curLvl]; } while ($curLvl < $l[0]); } else if ($curLvl > $l[0]) { // need to close the current level do { if (0 < $divOpen[$curLvl]) { $markup[] = '
    '; --$divOpen[$curLvl]; } // if --$curLvl; $markup[] .= '
  • '; if (0 < $divOpen[$curLvl]) { $markup[] = ''; --$divOpen[$curLvl]; } // if } while ($curLvl > $l[0]); $markup[] = '
  • '; ++$divOpen[$curLvl]; } else { // still the current nesting level if (0 < $divOpen[$curLvl]) { $markup[] = '
    '; } // if $markup[] = '
  • '; } // if // Prepare the current link by setting up // the HREF and TITLE attributes as appropriate: $l[0] = str_replace($this->_Chars, $this->_Ents, $l[1]); $markup[] = $link . ltrim(str_replace(':', '', cleanID($l[1])), '0123456789._-') . '" title="' . $l[0] . '">' . $l[0] . ''; } // while } // while // Finally close all possibly open DIV/LI/UL elements while (0 < $curLvl) { if (0 < $divOpen[$curLvl]) { $markup[] = '
    '; } // if $markup[] = '
  • '; --$curLvl; } // while // Return the list markup for the current document: return implode('', $markup); } // _doMarkup() /** * Add markup to load JavaScript/CSS with older DokuWiki versions. * * @param $aRenderer Object The renderer used. * @private * @since created 20-Feb-2007 * @see render() */ function _fixJS(&$aRenderer) { if ($this->_JSmarkup) { return; // Markup already added (or not needed) } // if //XXX This test will break if that DokuWiki file gets renamed: if (@file_exists(DOKU_INC . 'lib/exe/js.php')) { // Assuming a fairly recent DokuWiki installation // handling the plugin files on its own. $this->_JSmarkup = TRUE; return; } // if $localdir = realpath(dirname(__FILE__)) . '/'; $webdir = DOKU_BASE . 'lib/plugins/nstoc/'; $css = ''; if (file_exists($localdir . 'style.css')) { ob_start(); @include($localdir . 'style.css'); // Remove whitespace from CSS and expand IMG paths: if ($css = preg_replace( array('|\s*/\x2A.*?\x2A/\s*|s', '|\s*([:;\{\},+!])\s*|', '|(?:url\x28\s*)([^/])|', '|^\s*|', '|\s*$|'), array(' ', '\1', 'url(' . $webdir . '\1'), ob_get_contents())) { $css = ''; } // if ob_end_clean(); } // if $js = (file_exists($localdir . 'script.js')) ? '' : ''; if ($this->_JSmarkup = $css . $js) { // Place the additional markup at top'o'page: $aRenderer->doc = $this->_JSmarkup . preg_replace('|\s*

    \s*

    \s*|', '', $aRenderer->doc); } else { // Neither CSS nor JS files found. // Set member field to skip tests with next call: $this->_JSmarkup = TRUE; } // if } // _fixJS() /** * Get a list of the headlines in the given $aID page. * *

    * Each entry of the returned zero-based list is an array with the * respective headline's level at index 0 (zero) * and the headline's text at index 1 (one). *

    * @param $aID String The wiki ID to process. * @param $aStartLevel Integer The initial namespace depth. * @param $aMaxLevel Integer The max. nesting level allowed. * @param $aDecLevel Integer Number of levels to reduce the computed * level of the returned entries; either 0 (zero) or 1. * @return Mixed An array (list) of headlines or FALSE * if no headline markup was found. * @private * @see render() */ function _getHeadings(&$aID, &$aStartLevel, &$aMaxLevel, &$aDecLevel) { $absLvl = $aStartLevel + $aMaxLevel; // The prepended colon is essential to make sure we're always // starting with level "1" even if processing a page/file in // the root namespace: $cl = substr_count(':' . $aID, ':'); $hits = $result = array(); if ($c = preg_match_all('|\n[ \t]*(={2,6}?)[\t ]*?([^=][^\n]*[^=])\s*?\1|U', "\n" . io_readfile(wikiFN($aID), FALSE), $hits, PREG_SET_ORDER)) { for ($i = 0; $c > $i; ++$i) { if (($l = $cl + $this->_Hlevels[$hits[$i][1]]) && ($l < $absLvl)) { $result[] = array( ($l - $aStartLevel) - $aDecLevel, $hits[$i][2]); } // if unset($hits[$i]); // free mem } // for } // if // Return the list only if there was something found: return (0 < count($result)) ? $result : FALSE; } // _getHeadings() /** * Resolve the given $aPath in relation to the specified * $aNamespace. * *

    * This method tries to resolve relative and absolute * pathnames depending on the given $aNamespace value. *

    * Note that this implementation is not bulletproof but just uses * string operations for its intended purpose. * It's called by the public handle() method where further * checks are applied. *

    * @param $aNamespace String The base namespace of $aPath: * @param $aPath String The (possibly relative) path to resolve. * @return String The absolute namespace/page name. * @private * @since created 11-Aug-2007 * @see handle() * @static */ function _path($aNamespace, $aPath) { // Make sure the NS ends with a colon: if ($len = strlen($aNamespace)) { if (':' != $aNamespace[--$len]) { $aNamespace .= ':'; } // if } else { $aNamespace = ':'; } // if if ($len = strlen($aPath)) { if ('.' == $aPath) { return $aNamespace; } // if // Check for absolute path: if (':' == $aPath[0]) { return $aPath; } // if } else { // Empty path => return current namespace: return $aNamespace; } // if // Check for relative paths: if ((1 < $len) && ('.' == $aPath[0])) { if (':' == $aPath[1]) { return syntax_plugin_nstoc::_path($aNamespace, substr($aPath, 2)); } // if if ('.' == $aPath{1}) { // We use "preg_split()" instead of "explode()" to // omit empty entries: $path = preg_split('|:|', $aNamespace, -1, PREG_SPLIT_NO_EMPTY); if (count($path)) { // Remove the last NS element: array_pop($path); // Rebuild the whole NS path: $aNamespace = implode(':', $path); return ((2 < $len) && (':' == $aPath[2])) ? syntax_plugin_nstoc::_path($aNamespace, substr($aPath, 3)) : syntax_plugin_nstoc::_path($aNamespace, substr($aPath, 2)); } // if // Trying to go beyond the NS start ... return ':'; } // if } // if return $aNamespace . $aPath; } // _path() //@} /** * @publicsection */ //@{ /** * Tell the parser whether the plugin accepts syntax mode * $aMode within its own markup. * * @param $aMode String The requested syntaxmode. * @return Boolean FALSE always since no nested markup * is possible with this plugin. * @public */ function accepts($aMode) { return FALSE; } // accepts() /** * Connect lookup pattern to lexer. * * @param $aMode String The desired rendermode. * @public * @see render() */ function connectTo($aMode) { $this->Lexer->addSpecialPattern('\x7B\x7Bnstoc\s+[^\}\n\r]*\x7D\x7D', $aMode, 'plugin_nstoc'); } // connectTo() /** * Get an associative array with plugin info. * *

    * The returned array holds the following fields: *

    *
    author
    Author of the plugin
    *
    email
    Email address to contact the author
    *
    date
    Last modified date of the plugin in * YYYY-MM-DD format
    *
    name
    Name of the plugin
    *
    desc
    Short description of the plugin (Text only)
    *
    url
    Website with more information on the plugin * (eg. syntax description)
    *
    * @return Array Information about this plugin class. * @public * @static */ function getInfo() { return array( 'author' => 'Matthias Watermann', 'email' => 'support@mwat.de', 'date' => '2010-02-21', 'name' => 'NsToC Syntax Plugin', 'desc' => 'Add a namespace\'s table of contents {' . '{nstoc [namespace [maxdepth]]}}', 'url' => 'http://www.dokuwiki.org/plugin:nstoc'); } // getInfo() /** * Define how this plugin is handled regarding paragraphs. * * @return String "block" (open paragraphs need to be closed * before plugin output). * @public * @static */ function getPType() { return 'block'; } // getPType() /** * Where to sort in? * * @return Integer 298 * (smaller Doku_Parser_Mode_internallink). * @public * @static */ function getSort() { return 298; } // getSort() /** * Get the type of syntax this plugin defines. * * @return String "substition" (i.e. substitution). * @public * @static */ function getType() { return 'substition'; // sic! should be __substitution__ } // getType() /** * Handler to prepare matched data for the rendering process. * *

    * The $aState parameter gives the type of pattern * which triggered the call to this method: *

    *
    DOKU_LEXER_SPECIAL
    *
    a pattern set by addSpecialPattern()
    *

    * Any other $aState value results in a no-op. *

    * @param $aMatch String The text matched by the patterns. * @param $aState Integer The lexer state for the match; all states but * DOKU_LEXER_SPECIAL are ignored by this implementation. * @param $aPos Integer The character position of the matched text. * @param $aHandler Object Reference to the Doku_Handler object. * @return Array List of parsed data: Index * [0] holds the current $aState, * [1] the base namespace to process (possibly empty), * [2] the allowed nesting depth, * [3] the initial nesting depth of the given base namespace * and [4] a flag indicating whether to start with a file * (TRUE) or directory (FALSE). * @public * @see render() * @static */ function handle($aMatch, $aState, $aPos, &$aHandler) { if (DOKU_LEXER_SPECIAL != $aState) { // This causes "render()" to do nothing ... return array(DOKU_LEXER_EXIT); } // if // Extract the 0|1|2 arguments: $args = ($aMatch = substr($aMatch, 7, -2)) ? preg_split('|\s+|', $aMatch, -1, PREG_SPLIT_NO_EMPTY) : NULL; switch (count($args)) { case 0: $args = array('', 0); break; case 1: if (is_numeric($args[0])) { // There's a depth value only, make it numeric: $args[1] = $args[0] * 1; $args[0] = ''; } else { $args[0] = str_replace('/', ':', $args[0]); // There's a namespace only, add depth value: $args[1] = 0; } // if break; default: $args[0] = str_replace('/', ':', $args[0]); // Make the (assumed) depth value numeric: $args[1] *= 1; break; } // switch // Compute current page and namespace: $current = str_replace('/', ':', getID('id', FALSE)); $dir = ''; for ($f = strlen($current); 0 < $f; --$f) { if (':' == $current[$f]) { $dir = substr($current, 0, $f); break; } // if } // for // Resolve paths relative to current namespace $args[0] = syntax_plugin_nstoc::_path($dir, $args[0]); // Check whether we've got the index page of a namespace: global $conf; $idx = (empty($conf['start'])) ? 'start' : $conf['start']; if ($args[0] == $idx) { $args[0] = ''; } else { $idx = ':' . $idx; $f = strlen($idx) * -1; if (substr($args[0], $f) == $idx) { $args[0] = substr($args[0], 0, $f); } // if } // if $f = 0; // file flag // Now check whether we've got in fact a valid namespace/page: if ($ns = cleanID($args[0])) { // To compute the actual nesting level we have to test // whether the given ID refers to a file or directory. if ($f = file_exists($fn = wikiFN($ns))) { // If there is a file set the flag to FALSE if there's // a directory (i.e. namespace) with the same name: $f = (! is_dir(substr($fn, 0, -4))); } // if // Make the file flag's numeric so it's usable // for computing the actual starting level: $f *= 1; // Compute the initial nesting level: $args[0] = ($f) ? 2 + substr_count($ns, ':') : 1 + substr_count($ns, ':'); } else { // we're in the root namespace either explicitely or // by an argument that resolved to root. $args[0] = 1; } // if // Check the allowed nesting level value: if (0 < $args[1]) { if (! $f) { // For directories we need extra levels if ('' == $ns) { ++$args[1]; } else { $args[1] += 2; } // if } // if } else { //XXX In case no depth argument was given we use a // value of 255 which should be reasonably great enough // (see "_doMarkup()"). $args[1] = 0xff; } // if // Finally prepare the data used by "render()": return array(DOKU_LEXER_SPECIAL, $ns, $args[1], $args[0], (bool)$f); } // handle() /** * Handle the actual output creation. * *

    * The method checks for the given $aFormat and returns * FALSE when a format isn't supported. * $aRenderer contains a reference to the renderer object * which is currently handling the rendering. * The contents of $aData is the return value of the * handle() method. *

    * This implementation uses the precomputed values of $aData * to generate a list of headlines marked up as a (X)HTML list. *

    * @param $aFormat String The output format to generate. * @param $aRenderer Object Reference to the Doku_Renderer_xhtml * object to use. * @param $aData Array The data created/returned by the * handle() method. * @return Boolean TRUE if rendered successfully, or * FALSE otherwise. * @public * @see handle() */ function render($aFormat, &$aRenderer, &$aData) { if ('xhtml' != $aFormat) { return FALSE; // nothing to do for other formats } // if if (DOKU_LEXER_SPECIAL != $aData[0]) { return TRUE; // nothing to do for other states } // if global $conf; $ids = array(); if ($aData[4]) { // It's just a single file to process $ids[0] = $aData[1]; // The var is recycled to hold the level decrement value // used by "_getHeadings()" to compute the actual LI level // attribute: $aData[1] = -1; } else { // Unfortunately the global "search()" function isn't able // to use methods (even static class methods) but insists // on an ordinary function to be passed as a calltime // argument (at least up to DokuWiki 2006-03-05). // To avoid polluting the global namespace even more than // it already is we use a private member function which we // can pass to DokuWiki's global "search()" function. if (! $this->_callback) { $idx = (empty($conf['start'])) ? 'start' : $conf['start']; $iLen = (strlen($idx) + 1) * -1; // "+1" for the NS colon // Here we filter out the "index" pages i.e. pages either // named as configured in the global "$conf['start']" or // with the same name as a sub-directory. $this->_callback = create_function( '&$aData, $aBase, $aFile, $aType, $aLvl, $opts', 'if (("f" == $aType) && (".txt" == substr($aFile, -4))' . '&& (! is_dir($aBase . "/" . substr($aFile, 0, -4)))' . '&& ($aFile = pathID($aFile)) && ($aFile != "' . $idx . '")' . '&& (substr($aFile, ' . $iLen . ') != ":' . $idx . '")) {' . '$aData[] = $aFile;}' . 'return TRUE;'); } // if // Call DokuWiki's global search function: if ('' == $aData[1]) { search($ids, $conf['datadir'], $this->_callback, FALSE, $aData[1], 0); $aData[1] = 0; // setup level decrement for "_getHeadings()" } else { search($ids, $conf['datadir'], $this->_callback, FALSE, str_replace(':', '/', $aData[1]), 0); $aData[1] = 1; // setup level decrement for "_getHeadings()" } // if sort($ids); } // if global $USERINFO; $g =& $USERINFO['grps']; // Preparing references saves array .. $u =& $_SERVER['REMOTE_USER']; // .. lookups within the loops below. $pages = array(); // To avoid repeated boolean and regEx tests if unneeded // we unroll the loop saving lots of CPU cycles. if (empty($conf['hidepages'])) { while (list($i, $entry) = each($ids)) { unset($ids[$i]); // free mem // Use only pages which are actually // readable for the current user: if ((0 < auth_aclcheck($entry, $u, $g)) && ($i = $this->_getHeadings($entry, $aData[3], $aData[2], $aData[1]))) { $pages[$entry] = $i; } // if } // while unset($entry, $i, $ids); // free mem } else { $re = '/' . $conf['hidepages'] . '/ui'; while (list($i, $entry) = each($ids)) { unset($ids[$i]); // free mem // Use only pages which are actually readable for the // current user and not supposed to be "hidden": if ((0 < auth_aclcheck($entry, $u, $g)) && (! preg_match($re, ':' . $entry)) && ($i = $this->_getHeadings($entry, $aData[3], $aData[2], $aData[1]))) { $pages[$entry] = $i; } // if } // while unset($entry, $i, $ids, $re); // free mem } // if if (0 < count($pages)) { $this->_fixJS($aRenderer); // check for old DokuWiki versions $aRenderer->doc .= $this->_doMarkup($pages); } // if return TRUE; } // render() //@} } // class syntax_plugin_nstoc } // if ?>