* */ use dokuwiki\Extension\SyntaxPlugin; use dokuwiki\File\PageResolver; use dokuwiki\plugin\indexmenu\Search; use dokuwiki\Ui\Index; /** * All DokuWiki plugins to extend the parser/rendering mechanism * need to inherit from this class */ class syntax_plugin_indexmenu_indexmenu extends SyntaxPlugin { /** * What kind of syntax are we? */ public function getType() { return 'substition'; } /** * Behavior regarding the paragraph */ public function getPType() { return 'block'; } /** * Where to sort in? */ public function getSort() { return 138; } /** * Connect pattern to lexer * * @param string $mode */ public function connectTo($mode) { $this->Lexer->addSpecialPattern('{{indexmenu>.+?}}', $mode, 'plugin_indexmenu_indexmenu'); } /** * Handler to prepare matched data for the rendering process * * @param string $match The text matched by the patterns * @param int $state The lexer state for the match * @param int $pos The character position of the matched text * @param Doku_Handler $handler The Doku_Handler object * @return array Return an array with all data you want to use in render * * @throws Exception */ public function handle($match, $state, $pos, Doku_Handler $handler) { $theme = 'default'; // name of theme for images and additional css $level = -1; // requested depth of initial opened nodes, -1:all $max = 0; // number of levels loaded initially, rest should be loaded with ajax. (TODO actual default is 1) $maxAjax = 1; // number of levels loaded per ajax request $subNSs = []; $skipNsCombined = []; $skipFileCombined = []; $skipNs = ''; $skipFile = ''; /* @deprecated 2022-04-15 dTree only */ $maxJs = 1; /* @deprecated 2022-04-15 dTree only. Fancytree always random id */ $gen_id = 'random'; /* @deprecated 2021-07-01 -- allow (temporary) switching between versions of the js treemenu */ $jsVersion = 1; // 0:both, 1:dTree, 2:Fancytree /* @deprecated 2022-04-15 dTree only */ $jsAjax = ''; $defaultsStr = $this->getConf('defaultoptions'); $defaults = explode(' ', $defaultsStr); $match = substr($match, 12, -2); //split namespace,level,theme [$nsStr, $optsStr] = array_pad(explode('|', $match, 2), 2, ''); //split options $opts = explode(' ', $optsStr); //Context option $context = $this->hasOption($defaults, $opts, 'context'); //split subnamespaces with their level of open/closed nodes // PREG_SPLIT_NO_EMPTY flag filters empty pieces e.g. due to multiple spaces $nsStrs = preg_split("/ /u", $nsStr, -1, PREG_SPLIT_NO_EMPTY); //skips i=0 because that becomes main $ns $counter = count($nsStrs); //skips i=0 because that becomes main $ns for ($i = 1; $i < $counter; $i++) { $subns_lvl = explode("#", $nsStrs[$i]); //context should parse this later in correct context if (!$context) { $subns_lvl[0] = $this->parseNs($subns_lvl[0]); } $subNSs[] = [ $subns_lvl[0], //subns isset($subns_lvl[1]) && is_numeric($subns_lvl[1]) ? $subns_lvl[1] : -1 // level ]; } //empty pieces were filtered if ($nsStrs === []) { $nsStrs[0] = ''; } //split main requested namespace if (preg_match('/(.*)#(\S*)/u', $nsStrs[0], $matched_ns_lvl)) { //split level $ns = $matched_ns_lvl[1]; if (is_numeric($matched_ns_lvl[2])) { $level = (int)$matched_ns_lvl[2]; } } else { $ns = $nsStrs[0]; } //context needs to be resolved later if (!$context) { $ns = $this->parseNs($ns); } //nocookie option (disable for uncached pages) /* @deprecated 2023-11 dTree only?, too complex */ $nocookie = $context || $this->hasOption($defaults, $opts, 'nocookie'); //noscroll option /** @deprecated 2023-11 dTree only and too complex */ $noscroll = $this->hasOption($defaults, $opts, 'noscroll'); //Open at current namespace option $navbar = $this->hasOption($defaults, $opts, 'navbar'); //no namespaces options $nons = $this->hasOption($defaults, $opts, 'nons'); //no pages option $nopg = $this->hasOption($defaults, $opts, 'nopg'); //disable toc preview $notoc = $this->hasOption($defaults, $opts, 'notoc'); //disable the right context menu $nomenu = $this->hasOption($defaults, $opts, 'nomenu'); //Main sort method $tsort = $this->hasOption($defaults, $opts, 'tsort'); $dsort = $this->hasOption($defaults, $opts, 'dsort'); if ($tsort) { $sort = 't'; } elseif ($dsort) { $sort = 'd'; } else { $sort = 0; } //sort directories in the same way as files $nsort = $this->hasOption($defaults, $opts, 'nsort'); //sort headpages up $hsort = $this->hasOption($defaults, $opts, 'hsort'); //Metadata sort method if ($msort = $this->hasOption($defaults, $opts, 'msort')) { $msort = 'indexmenu_n'; } elseif ($value = $this->getOption($defaultsStr, $optsStr, '/msort#(\S+)/u')) { $msort = str_replace(':', ' ', $value); } //reverse sort $rsort = $this->hasOption($defaults, $opts, 'rsort'); if ($sort) $jsAjax .= "&sort=" . $sort; if ($msort) $jsAjax .= "&msort=" . $msort; if ($rsort) $jsAjax .= "&rsort=1"; if ($nsort) $jsAjax .= "&nsort=1"; if ($hsort) $jsAjax .= "&hsort=1"; if ($nopg) $jsAjax .= "&nopg=1"; //javascript option $dir = ''; //check defaults for js,js#theme, #theme if (!$js = in_array('js', $defaults)) { if (preg_match('/(?:^|\s)(js)?#(\S*)/u', $defaultsStr, $matched_js_theme) > 0) { if (!empty($matched_js_theme[1])) { $js = true; } if (isset($matched_js_theme[2])) { $dir = $matched_js_theme[2]; } } } //check opts for nojs,#theme or js,js#theme if ($js) { if (in_array('nojs', $opts)) { $js = false; } elseif (preg_match('/(?:^|\s)(?:js)?#(\S*)/u', $optsStr, $matched_theme) > 0) { if (isset($matched_theme[1])) { $dir = $matched_theme[1]; } } } elseif ($js = in_array('js', $opts)) { //use theme from the defaults } elseif (preg_match('/(?:^|\s)js#(\S*)/u', $optsStr, $matched_theme) > 0) { $js = true; if (isset($matched_theme[1])) { $dir = $matched_theme[1]; } } if ($js) { //exist theme? if (!empty($dir) && is_dir(DOKU_PLUGIN . "indexmenu/images/" . $dir)) { $theme = $dir; } //id generation method /* @deprecated 2023-11 not needed anymore */ $gen_id = $this->getOption($defaultsStr, $optsStr, '/id#(\S+)/u'); //max option: #n is no of lvls during initialization , #m levels retrieved per ajax request $matchPattern = '/max#(\d+)(?:$|\s+|#(\d+))/u'; if ($matched_lvl_sublvl = $this->getOption($defaultsStr, $optsStr, $matchPattern, true)) { $max = $matched_lvl_sublvl[1]; if (!empty($matched_lvl_sublvl[2])) { $jsAjax .= "&max=" . $matched_lvl_sublvl[2]; $maxAjax = (int)$matched_lvl_sublvl[2]; } //disable cookie to avoid javascript errors $nocookie = true; } else { $max = 0; //todo current default seems 1. } //max js option if ($maxjs_lvl = $this->getOption($defaultsStr, $optsStr, '/maxjs#(\d+)/u')) { $maxJs = $maxjs_lvl; } /* @deprecated 2021-07-01 -- allow (temporary) switching between versions of the js treemenu */ $treeNew = $this->hasOption($defaults, $opts, 'treenew'); //overrides old and both /* @deprecated 2021-07-01 -- allow (temporary) switching between versions of the js treemenu */ $treeOld = $this->hasOption($defaults, $opts, 'treeold'); //overrides both /* @deprecated 2021-07-01 -- allow (temporary) switching between versions of the js treemenu */ $treeBoth = $this->hasOption($defaults, $opts, 'treeboth'); // $jsVersion = $treeNew ? 2 : ($treeOld ? 1 : ($treeBoth ? 0 : $jsVersion)); $jsVersion = $treeOld ? 1 : ($treeNew ? 2 : ($treeBoth ? 0 : $jsVersion)); // error_log('$treeOld:'.$treeOld.'$treeNew:'.$treeNew.'$treeBoth:'.$treeBoth); if ($jsVersion !== 1) { //check for theme of fancytree (overrides old dTree theme eventually?) if (!empty($dir) && is_dir(DOKU_PLUGIN . 'indexmenu/scripts/fancytree/skin-' . $dir)) { $theme = $dir; } // $theme='default' is later overwritten by 'win7' } } if (is_numeric($gen_id)) { /* @deprecated 2023-11 not needed anymore */ $identifier = $gen_id; } elseif ($gen_id == 'ns') { $identifier = sprintf("%u", crc32($ns)); } else { $identifier = uniqid(random_int(0, mt_getrandmax())); } //skip namespaces in index $skipNsCombined[] = $this->getConf('skip_index'); if (preg_match('/skipns[+=](\S+)/u', $optsStr, $matched_skipns) > 0) { //first sign is: '+' (parallel to conf) or '=' (replace conf) $action = $matched_skipns[0][6]; $index = 0; if ($action == '+') { $index = 1; } //directly used in search $skipNsCombined[$index] = $matched_skipns[1]; //fancytree $skipNs = ($action == '+' ? '+' : '=') . $matched_skipns[1]; //dTree $jsAjax .= "&skipns=" . utf8_encodeFN(($action == '+' ? '+' : '=') . $matched_skipns[1]); } //skip file $skipFileCombined[] = $this->getConf('skip_file'); if (preg_match('/skipfile[+=](\S+)/u', $optsStr, $matched_skipfile) > 0) { //first sign is: '+' (parallel to conf) or '=' (replace conf) $action = $matched_skipfile[0][8]; $index = 0; if ($action == '+') { $index = 1; } //directly used in search $skipFileCombined[$index] = $matched_skipfile[1]; //fancytree $skipFile = ($action == '+' ? '+' : '=') . $matched_skipfile[1]; //dTree $jsAjax .= "&skipfile=" . utf8_encodeFN(($action == '+' ? '+' : '=') . $matched_skipfile[1]); } //js options return [ $ns, //0 [ //1=js_dTreeOpts 'theme' => $theme, 'identifier' => $identifier, //deprecated 'nocookie' => $nocookie, //deprecated 'navbar' => $navbar, 'noscroll' => $noscroll, //deprecated 'maxJs' => $maxJs, //deprecated 'notoc' => $notoc, //will be changed to default notoc 'jsAjax' => $jsAjax, //deprecated 'context' => $context, //only in handler()? 'nomenu' => $nomenu //will be changed to default nomenu ], [ //2=sort 'sort' => $sort, 'msort' => $msort, 'rsort' => $rsort, 'nsort' => $nsort, 'hsort' => $hsort, ], [ //3=opts 'level' => $level, // requested depth of initial opened nodes, -1:all 'nons' => $nons, 'nopg' => $nopg, 'subnss' => $subNSs, //only used for initial load 'navbar' => $navbar, //add current ns to subNSs, for initial load 'max' => $max, //number of levels loaded initially, rest should be loaded with ajax 'maxajax' => $maxAjax, //number of levels loaded per ajax request 'js' => $js, 'skipnscombined' => $skipNsCombined, 'skipfilecombined' => $skipFileCombined, 'skipns' => $skipNs, 'skipfile' => $skipFile, 'headpage' => $this->getConf('headpage'), 'hide_headpage' => $this->getConf('hide_headpage'), 'theme' => $theme ], $jsVersion //4 ]; } /** * Looks if the default options and syntax options has the requested option * * @param array $defaultsOpts array of default options * @param array $opts array of options provided via syntax * @param string $optionName name of requested option * @return bool has $optionName? */ private function hasOption($defaultsOpts, $opts, $optionName) { $name = $optionName; if (substr($optionName, 0, 2) == 'no') { $inverseName = substr($optionName, 2); } else { $inverseName = 'no' . $optionName; } if (in_array($name, $defaultsOpts)) { return !in_array($inverseName, $opts); } else { return in_array($name, $opts); } } /** * Looks for the value of the requested option in the default options and syntax options * * @param string $defaultsString default options string * @param string $optsString syntax options string * @param string $matchPattern pattern to search for * @param bool $multipleMatches if multiple returns array, otherwise the first match * @return string|array */ private function getOption($defaultsString, $optsString, $matchPattern, $multipleMatches = false) { if (preg_match($matchPattern, $optsString, $match_o) > 0) { if ($multipleMatches) { return $match_o; } else { return $match_o[1]; } } elseif (preg_match($matchPattern, $defaultsString, $match_d) > 0) { if ($multipleMatches) { return $match_d; } else { return $match_d[1]; } } return false; } /** * Handles the actual output creation. * * @param string $format output format being rendered * @param Doku_Renderer $renderer the current renderer object * @param array $data data created by handler() * @return boolean rendered correctly? */ public function render($format, Doku_Renderer $renderer, $data) { global $ACT; global $conf; global $INFO; $ns = $data[0]; //theme, identifier, nocookie, navbar, noscroll, maxJs, notoc, jsAjax, context, nomenu $js_dTreeOpts = $data[1]; //sort, msort, rsort, nsort, hsort $sort = $data[2]; //opts for search(): level, nons, nopg, subnss, max, maxajax, js, skipns, skipfile, skipnscombined, //skipfilecombined, headpage, hide_headpage $opts = $data[3]; /* @deprecated 2021-07-01 temporary */ $jsVersion = $data[4]; if ($format == 'xhtml') { if ($ACT == 'preview') { //Check user permission to display indexmenu in a preview page if ( $this->getConf('only_admins') && $conf['useacl'] && $INFO['perm'] < AUTH_ADMIN ) { return false; } //disable cookies $js_dTreeOpts['nocookie'] = true; } if ($opts['js'] & $conf['defer_js']) { msg( 'Indexmenu Plugin: If you use the \'js\'-option of the indexmenu plugin, you have to ' . 'disable the \'defer_js\'-setting. ' . 'This setting is temporary, in the future the indexmenu plugin will be improved.', -1 ); } //Navbar with nojs if ($js_dTreeOpts['navbar'] && !$opts['js']) { if (!isset($ns)) { $ns = ':'; } //add ns of current page to let open these nodes (within the $ns), open only 1 level. $currentNS = getNS($INFO['id']); if ($currentNS !== false) { $opts['subnss'][] = [$currentNS, 1]; } $renderer->info['cache'] = false; } if ($js_dTreeOpts['context']) { //resolve ns and subns's relative to current wiki page (instead of sidebar) $ns = $this->parseNs($ns, $INFO['id']); foreach ($opts['subnss'] as $key => $value) { $opts['subnss'][$key][0] = $this->parseNs($value[0], $INFO['id']); } $renderer->info['cache'] = false; } //build index $html = $this->buildHtmlIndexmenu($ns, $js_dTreeOpts, $sort, $opts, $jsVersion); //alternative if empty if (!@$html) { $html = $this->getConf('empty_msg'); $html = str_replace('{{ns}}', cleanID($ns), $html); $html = p_render('xhtml', p_get_instructions($html), $info); } $renderer->doc .= $html; return true; } elseif ($format == 'metadata') { /** @var Doku_Renderer_metadata $renderer */ if (!($js_dTreeOpts['navbar'] && !$opts['js']) && !$js_dTreeOpts['context']) { //this is an indexmenu page that needs the PARSER_CACHE_USE event trigger; $renderer->meta['indexmenu']['hasindexmenu'] = true; } //summary $renderer->doc .= (empty($ns) ? $conf['title'] : nons($ns)) . " index\n\n"; unset($renderer->persistent['indexmenu']); return true; } else { return false; } } /** * Return the index * * @param string $ns * @param array $js_dTreeOpts entries: theme, identifier, nocookie, navbar, noscroll, maxJs, notoc, jsAjax, context, * nomenu * @param array $sort entries: sort, msort, rsort, nsort, hsort * @param array $opts entries of opts for search(): level, nons, nopg, nss, max, maxajax, js, skipns, skipfile, * skipnscombined, skipfilecombined, headpage, hide_headpage * @param int $jsVersion * @return bool|string return html for a nojs index and when enabled the js rendered index, otherwise false * * @author Samuele Tognini */ private function buildHtmlIndexmenu($ns, $js_dTreeOpts, $sort, $opts, $jsVersion) { $js_name = "indexmenu_" . $js_dTreeOpts['identifier']; //TODO temporary hack, to switch in Search between searchIndexmenuItemsNew() and searchIndexmenuItems() $opts['tempNew'] = false; $search = new Search($sort); $nodes = $search->search($ns, $opts); if (!$nodes) return false; // javascript index $output_js = ''; if ($opts['js']) { $ns = str_replace('/', ':', $ns); // $jsversion: 0:both, 1:dTree, 2:Fancytree if ($jsVersion < 2) { $output_js .= $this->builddTree($nodes, $ns, $js_dTreeOpts, $js_name, $opts['max']); } if ($jsVersion !== 1) { $output_js .= $this->buildFancyTree($js_name, $ns, $opts, $sort); } //remove unwanted nodes from standard index $this->cleanNojsData($nodes); } $output = "\n"; $output .= $this->buildNoJSTree($nodes, $js_name, $js_dTreeOpts['jsAjax']); $output .= $output_js; return $output; } private function buildNoJSTree($nodes, $js_name, $jsAjax) { // Nojs dokuwiki index // extra div needed when index is first element in sidebar of dokuwiki template, template uses this to // toggle sidebar the toggle interacts with hide needed for js option. $idx = new Index(); return '
' . '
' . html_buildlist($nodes, 'idx', [$this, 'formatIndexmenuItem'], [$idx, 'tagListItem']) . '
' . '
'; } private function buildFancyTree($js_name, $ns, $opts, $sort) { global $conf; //not needed, because directly retrieved from config unset($opts['headpage']); unset($opts['hide_headpage']); unset($opts['js']); //always true unset($opts['skipnscombined']); unset($opts['skipfilecombined']); /* @deprecated 2023-08-14 remove later */ if ($opts['theme'] == 'default') { $opts['theme'] = 'win7'; } $options = [ 'ns' => $ns, 'opts' => $opts, 'sort' => $sort, 'contextmenu' => false, 'startpage' => $conf['start'] //needed? or for contextmenu? ]; return '
'; } /** * Build the browsable index of pages using javascript * * @param array $nodes array with items of the tree * @param string $ns requested namespace * @param array $js_dTreeOpts options for javascript renderer * @param string $js_name identifier for this index * @param int $max the node at $max level will retrieve all its child nodes through the AJAX mechanism * @return bool|string returns inline javascript or false * * @author Samuele Tognini * @author Rene Hadler * * @deprecated 2023-11 will be replace by Fancytree */ private function builddTree($nodes, $ns, $js_dTreeOpts, $js_name, $max) { global $conf; $hns = false; if (empty($nodes)) { return false; } //TODO jsAjax is empty?? while max is set to 1 // Render requested ns as root $headpage = $this->getConf('headpage'); // if rootnamespace and headpage, then add startpage as headpage // TODO seems not logic, when desired use $conf[headpage]=:start: ?? if (empty($ns) && !empty($headpage)) { $headpage .= ',' . $conf['start']; } $title = Search::getNamespaceTitle($ns, $headpage, $hns); if (empty($title)) { if (empty($ns)) { $title = hsc($conf['title']); } else { $title = $ns; } } // inline javascript $out = "\n"; return $out; } /** * Return array of javascript nodes and nodes to open. * * @param array $nodes array with items of the tree * @param string $js_name identifier for this index * @param boolean $noajax return as inline js (=true) or array for ajax response (=false) * @return array|bool returns array with * - a string of the javascript nodes * - and a string of space separated numbers of the opened nodes * or false when no data provided * * @author Samuele Tognini * * @deprecated 2023-11 will be replace by Fancytree */ public function builddTreeNodes($nodes, $js_name, $noajax = true) { if (empty($nodes)) { return false; } //Array of nodes to check $q = ['0']; //Current open node $currentOpenNode = 0; $out = ''; $openNodes = ''; if ($noajax) { $jscmd = $js_name . ".add"; $separator = ";\n"; } else { $jscmd = "new Array "; $separator = ","; } foreach ($nodes as $i => $node) { $i++; //Remove already processed nodes (greater level = lower level) while (isset($nodes[end($q) - 1]) && $node['level'] <= $nodes[end($q) - 1]['level']) { array_pop($q); } //till i found its father node if ($node['level'] == 1) { //root node $father = '0'; } else { //Father node $father = end($q); } //add node and its options if ($node['type'] == 'd') { //Search the lowest open node of a tree branch in order to open it. if ($node['open']) { if ($node['level'] < $nodes[$currentOpenNode]['level']) { $currentOpenNode = $i; } else { $openNodes .= "$i "; } } //insert node in last position $q[] = $i; } $out .= $jscmd . "('" . idfilter($node['id'], false) . "',$i," . $father . "," . json_encode($node['title']); //hns if ($node['hns']) { $out .= ",'" . idfilter($node['hns'], false) . "'"; } else { $out .= ",0"; } if ($node['type'] == 'd' || $node['type'] == 'l') { $out .= ",1"; } else { $out .= ",0"; } //MAX option if ($node['type'] == 'l') { $out .= ",1"; } else { $out .= ",0"; } $out .= ")" . $separator; } $openNodes = rtrim($openNodes, ' '); return [$out, $openNodes]; } /** * Parse namespace request * * @param string $ns namespaceid * @param bool $id page id to resolve $ns relative to. * @return string id of namespace * * @author Samuele Tognini */ public function parseNs($ns, $id = false) { if ($id === false) { global $ID; $id = $ID; } //Just for old releases compatibility, .. was an old version for : in the docs of indexmenu if ($ns == '..') { $ns = ":"; } $ns = "$ns:arandompagehere"; $resolver = new PageResolver($id); $ns = getNs($resolver->resolveId($ns)); return $ns === false ? '' : $ns; } /** * Clean index data from unwanted nodes in nojs mode. * * @param array $nodes nodes of the tree * @return void * * @author Samuele Tognini */ private function cleanNojsData(&$nodes) { $a = 0; foreach ($nodes as $i => $node) { //all entries before $a are unset if ($i < $a) { continue; } //closed node if ($node['type'] == "d" && !$node['open']) { $a = $i + 1; $level = $node['level']; //search and remove every lower and closed nodes while (isset($nodes[$a]) && $nodes[$a]['level'] > $level && !$nodes[$a]['open']) { unset($nodes[$a]); $a++; } } } } /** * Callback to print a Indexmenu item * * User function for @param array $item item described by array with at least the entries * - id page id/namespace id * - type 'd', 'l'(directory which is not yet opened) or 'f' * - open is node open * - title title of link * - hns page id of headpage of the namespace or false * @return string html of the content of a list item * * @author Samuele Tognini * @author Rik Blok * @author Andreas Gohr * * @see html_buildlist() */ public function formatIndexmenuItem($item) { global $INFO; $ret = ''; //namespace if ($item['type'] == 'd' || $item['type'] == 'l') { $markCurrentPage = false; $link = $item['id']; $more = 'idx=' . $item['id']; //namespace link if ($item['hns']) { $link = $item['hns']; $tagid = "indexmenu_idx_head"; $more = ''; //current page is shown? $markCurrentPage = $this->getConf('hide_headpage') && $item['hns'] == $INFO['id']; } else { //namespace without headpage $tagid = "indexmenu_idx"; if ($item['open']) { $tagid .= ' open'; } } if ($markCurrentPage) { $ret .= ''; } $ret .= '' . $item['title'] . ''; if ($markCurrentPage) { $ret .= ''; } return $ret; } else { //page link return html_wikilink(':' . $item['id']); } } }