sort = $sort['sort']; $this->msort = $sort['msort']; $this->rsort = $sort['rsort']; $this->nsort = $sort['nsort']; $this->hsort = $sort['hsort']; } /** * Build the data array for fancytree from search results * * @param array $data results from search * @param bool $isInit true if first level of nodes from tree, false if next levels * @param bool $currentPage current wikipage id * @param bool $isNopg if nopg is set * @return array */ public function buildFancytreeData($data, $isInit, $currentPage, $isNopg) { if (empty($data)) return []; $children = []; $opts = [ 'currentPage' => $currentPage, 'isParentLazy' => false, 'nopg' => $isNopg ]; $hasActiveNode = false; $this->makeNodes($data, -1, 0, $children, $hasActiveNode, $opts); if ($isInit) { $nodes['children'] = $children; return $nodes; } else { return $children; } } /** * Collects the children at the same level since last parsed item * * @param array $data results from search * @param int $indexLatestParsedItem * @param int $previousLevel level of parent * @param array $nodes by reference, here the child nodes are stored * @param bool $hasActiveNode active node must be unique, needs tracking * @param array $opts * @return int latest parsed item from data array */ private function makeNodes(&$data, $indexLatestParsedItem, $previousLevel, &$nodes, &$hasActiveNode, $opts) { $i = 0; $counter = 0; foreach ($data as $i => $item) { //skip parsed items if ($i <= $indexLatestParsedItem) { continue; } if ($item['level'] < $previousLevel || $counter === 0 && $item['level'] == $previousLevel) { return $i - 1; } $node = [ 'title' => $item['title'], 'key' => $item['id'] . ($item['type'] === 'f' ? '' : ':'), //ensure ns is unique 'hns' => $item['hns'] //false if not available ]; // f=file, d=directory, l=directory which is lazy loaded later if ($item['type'] == 'f') { // let php create url (considering rewriting etc) $node['url'] = wl($item['id']); //set current page to active if ($opts['currentPage'] == $item['id']) { if (!$hasActiveNode) { $node['active'] = true; $hasActiveNode = true; } } } else { // type: d/l $node['folder'] = true; // let php create url (considering rewriting etc) $node['url'] = $item['hns'] === false ? false : wl($item['hns']); if (!$item['hnsExists']) { //change link color $node['hnsNotExisting'] = true; } if ($item['open'] === true) { $node['expanded'] = true; } $node['children'] = []; $indexLatestParsedItem = $this->makeNodes( $data, $i, $item['level'], $node['children'], $hasActiveNode, [ 'currentPage' => $opts['currentPage'], 'isParentLazy' => $item['type'] === 'l', 'nopg' => $opts['nopg'] ] ); // a lazy node, but because we have sometime no pages or nodes (due e.g. acl/hidden/nopg), it could be // empty. Therefore we did extra work by walking a level deeper and check here whether it has children if ($item['type'] === 'l') { if (empty($node['children'])) { //an empty lazy node, is not marked lazy if ($opts['isParentLazy']) { //a lazy node with a lazy parent has no children loaded, so stays always empty //(these nodes are not really used, but only counted) $node['lazy'] = true; unset($node['children']); } } else { //has children, so mark lazy $node['lazy'] = true; unset($node['children']); //do not keep, because these nodes do not know yet their child folders } } //might be duplicated if hide_headpage is disabled, or with nopg and a :same: headpage //mark active after processing children, such that deepest level is activated if ( $item['hns'] === $opts['currentPage'] || $opts['nopg'] && getNS($opts['currentPage']) === $item['id'] ) { //with hide_headpage enabled, the parent node must be actived //special: nopg has no pages, therefore, mark its parent node active if (!$hasActiveNode) { $node['active'] = true; $hasActiveNode = true; } } } if ($item['type'] === 'f' || !empty($node['children']) || isset($node['lazy']) || $item['hns'] !== false) { // add only files, non-empty folders, lazy-loaded or folder with only a headpage $nodes[] = $node; } $previousLevel = $item['level']; $counter++; } return $i; } /** * Search pages/folders depending on the given options $opts * * @param string $ns * @param array $opts * @return array The results of the search */ public function search($ns, $opts): array { global $conf; if (!empty($opts['tempNew'])) { //a specific callback for Fancytree $callback = [$this, 'searchIndexmenuItemsNew']; } else { $callback = [$this, 'searchIndexmenuItems']; } $dataDir = $conf['datadir']; $data = []; $fsDir = "/" . utf8_encodeFN(str_replace(':', '/', $ns)); if ($this->sort || $this->msort || $this->rsort || $this->hsort) { $this->customSearch($data, $dataDir, $callback, $opts, $fsDir); } else { search($data, $dataDir, $callback, $opts, $fsDir); } return $data; } /** * Callback that adds an item of namespace/page to the browsable index, if it fits in the specified options * * @param array $data Already collected nodes * @param string $base Where to start the search, usually this is $conf['datadir'] * @param string $file Current file or directory relative to $base * @param string $type Type either 'd' for directory or 'f' for file * @param int $lvl Current recursion depth * @param array $opts Option array as given to search(): * @return bool if this directory should be traversed (true) or not (false) * * @author Andreas Gohr * modified by Samuele Tognini */ public function searchIndexmenuItems(&$data, $base, $file, $type, $lvl, $opts) { global $conf; $hns = false; $isOpen = false; $title = null; $skipns = $opts['skipnscombined']; $skipfile = $opts['skipfilecombined']; $headpage = $opts['headpage']; $id = pathID($file); if ($type == 'd') { // Skip folders in plugin conf foreach ($skipns as $skipn) { if (!empty($skipn) && preg_match($skipn, $id)) { return false; } } //check ACL (for sneaky_index namespaces too). if ($conf['sneaky_index'] && auth_quickaclcheck($id . ':') < AUTH_READ) return false; //Open requested level if ($opts['level'] > $lvl || $opts['level'] == -1) { $isOpen = true; } //Search optional subnamespaces with if (!empty($opts['subnss'])) { $subnss = $opts['subnss']; $counter = count($subnss); for ($a = 0; $a < $counter; $a++) { if (preg_match("/^" . $id . "($|:.+)/i", $subnss[$a][0], $match)) { //It contains a subnamespace $isOpen = true; } elseif (preg_match("/^" . $subnss[$a][0] . "(:.*)/i", $id, $match)) { //It's inside a subnamespace, check level // -1 is open all, otherwise count number of levels in the remainer of the pageid // (match[0] is always prefixed with :) if ($subnss[$a][1] == -1 || substr_count($match[1], ":") < $subnss[$a][1]) { $isOpen = true; } else { $isOpen = false; } } } } //decide if it should be traversed if ($opts['nons']) { return $isOpen; // in nons, level is only way to show/hide nodes (in nons nodes are not expandable) } elseif ($opts['max'] > 0 && !$isOpen && $lvl >= $opts['max']) { //Stop recursive searching $shouldBeTraversed = false; //change type $type = "l"; } elseif ($opts['js']) { $shouldBeTraversed = true; //TODO if js tree, then traverse deeper??? } else { $shouldBeTraversed = $isOpen; } //Set title and headpage $title = static::getNamespaceTitle($id, $headpage, $hns); // when excluding page nodes: guess a headpage based on the headpage setting if ($opts['nopg'] && $hns === false) { $hns = $this->guessHeadpage($headpage, $id); } } else { //Nopg. Dont show pages if ($opts['nopg']) return false; $shouldBeTraversed = true; //Nons.Set all pages at first level if ($opts['nons']) { $lvl = 1; } //don't add if (substr($file, -4) != '.txt') return false; //check hiddens and acl if (isHiddenPage($id) || auth_quickaclcheck($id) < AUTH_READ) return false; //Skip files in plugin conf foreach ($skipfile as $skipf) { if (!empty($skipf) && preg_match($skipf, $id)) { return false; } } //Skip headpages to hide (nons has no namespace nodes, therefore, no duplicated links to headpage) if (!$opts['nons'] && !empty($headpage) && $opts['hide_headpage']) { //start page is in root if ($id == $conf['start']) return false; $ahp = explode(",", $headpage); foreach ($ahp as $hp) { switch ($hp) { case ":inside:": if (noNS($id) == noNS(getNS($id))) return false; break; case ":same:": if (@is_dir(dirname(wikiFN($id)) . "/" . utf8_encodeFN(noNS($id)))) return false; break; //it' s an inside start case ":start:": if (noNS($id) == $conf['start']) return false; break; default: if (noNS($id) == cleanID($hp)) return false; } } } //Set title if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') { $title = p_get_first_heading($id, false); } if (is_null($title)) { $title = noNS($id); } $title = hsc($title); } $item = [ 'id' => $id, 'type' => $type, 'level' => $lvl, 'open' => $isOpen, 'title' => $title, 'hns' => $hns, 'file' => $file, 'shouldBeTraversed' => $shouldBeTraversed ]; $item['sort'] = $this->getSortValue($item); $data[] = $item; return $shouldBeTraversed; } /** * Callback that adds an item of namespace/page to the browsable index, if it fits in the specified options * * TODO Version as used for Fancytree js tree * * @param array $data indexed array of collected nodes, each item has: * @param string $base Where to start the search, usually this is $conf['datadir'] * @param string $file Current file or directory relative to $base * @param string $type Type either 'd' for directory or 'f' for file * @param int $lvl Current recursion depth * @param array $opts Option array as given to search() * @return bool if this directory should be traversed (true) or not (false) * * @author Andreas Gohr * modified by Samuele Tognini */ public function searchIndexmenuItemsNew(&$data, $base, $file, $type, $lvl, $opts) { global $conf; $hns = false; $isOpen = false; $title = null; $skipns = $opts['skipnscombined']; $skipfile = $opts['skipfilecombined']; $headpage = $opts['headpage']; $hnsExists = true; //nopg guesses pages $id = pathID($file); if ($type == 'd') { // Skip folders in plugin conf foreach ($skipns as $skipn) { if (!empty($skipn) && preg_match($skipn, $id)) { return false; } } //check ACL (for sneaky_index namespaces too). if ($conf['sneaky_index'] && auth_quickaclcheck($id . ':') < AUTH_READ) return false; //Open requested level if ($opts['level'] > $lvl || $opts['level'] == -1) { $isOpen = true; } //Search optional subnamespaces with $isFolderAdjacentToSubNss = false; if (!empty($opts['subnss'])) { $subnss = $opts['subnss']; $counter = count($subnss); for ($a = 0; $a < $counter; $a++) { if (preg_match("/^" . $id . "($|:.+)/i", $subnss[$a][0], $match)) { //this folder contains a subnamespace $isOpen = true; } elseif (preg_match("/^" . $subnss[$a][0] . "(:.*)/i", $id, $match)) { //this folder is inside a subnamespace, check level if ($subnss[$a][1] == -1 || substr_count($match[1], ":") < $subnss[$a][1]) { $isOpen = true; } else { $isOpen = false; } } elseif ( preg_match( "/^" . (($ns = getNS($id)) === false ? '' : $ns) . "($|:.+)/i", $subnss[$a][0], $match ) ) { // parent folder contains a subnamespace, if level deeper it does not match anymore // that is handled with normal >max handling $isOpen = false; if ($opts['max'] > 0) { $isFolderAdjacentToSubNss = true; } } } } //decide if it should be traversed if ($opts['nons']) { return $isOpen; // in nons, level is only way to show/hide nodes (in nons nodes are not expandable) } elseif ($opts['max'] > 0 && !$isOpen) { // note: for Fancytree >=1 is used // limited levels per request, node is closed if ($lvl == $opts['max'] || $isFolderAdjacentToSubNss) { // change type, more nodes should be loaded by ajax, but for nopg we need extra level to determine // if folder is empty // and folders adjacent to subns must be traversed as well $type = "l"; $shouldBeTraversed = true; } elseif ($lvl > $opts['max']) { // deeper lvls only used temporary for checking existance children //change type, more nodes should be loaded by ajax $type = "l"; // use lazy loading $shouldBeTraversed = false; } else { //node is closed, but still more levels requested with max $shouldBeTraversed = true; } } else { $shouldBeTraversed = $isOpen; } //Set title and headpage $title = static::getNamespaceTitle($id, $headpage, $hns); // when excluding page nodes: guess a headpage based on the headpage setting if ($opts['nopg'] && $hns === false) { $hns = $this->guessHeadpage($headpage, $id); $hnsExists = false; } } else { //Nopg.Dont show pages if ($opts['nopg']) return false; $shouldBeTraversed = true; //Nons.Set all pages at first level if ($opts['nons']) { $lvl = 1; } //don't add if (substr($file, -4) != '.txt') return false; //check hiddens and acl if (isHiddenPage($id) || auth_quickaclcheck($id) < AUTH_READ) return false; //Skip files in plugin conf foreach ($skipfile as $skipf) { if (!empty($skipf) && preg_match($skipf, $id)) { return false; } } //Skip headpages to hide if (!$opts['nons'] && !empty($headpage) && $opts['hide_headpage']) { //start page is in root if ($id == $conf['start']) return false; $hpOptions = explode(",", $headpage); foreach ($hpOptions as $hp) { switch ($hp) { case ":inside:": if (noNS($id) == noNS(getNS($id))) return false; break; case ":same:": if (@is_dir(dirname(wikiFN($id)) . "/" . utf8_encodeFN(noNS($id)))) return false; break; //it' s an inside start case ":start:": if (noNS($id) == $conf['start']) return false; break; default: if (noNS($id) == cleanID($hp)) return false; } } } //Set title if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') { $title = p_get_first_heading($id, false); } if (is_null($title)) { $title = noNS($id); } $title = hsc($title); } $item = [ 'id' => $id, 'type' => $type, 'level' => $lvl, 'open' => $isOpen, 'title' => $title, 'hns' => $hns, 'hnsExists' => $hnsExists, 'file' => $file, 'shouldBeTraversed' => $shouldBeTraversed ]; $item['sort'] = $this->getSortValue($item); $data[] = $item; return $shouldBeTraversed; } /** * callback that recurse directory * * This function recurses into a given base directory * and calls the supplied function for each file and directory * * Similar to search() of inc/search.php, but has extended sorting options * * @param array $data The results of the search are stored here * @param string $base Where to start the search * @param callback $func Callback (function name or array with object,method) * @param array $opts List of indexmenu options * @param string $dir Current directory beyond $base * @param int $lvl Recursion Level * * @author Andreas Gohr * @author modified by Samuele Tognini */ public function customSearch(&$data, $base, $func, $opts, $dir = '', $lvl = 1) { $dirs = []; $files = []; $files_tmp = []; $dirs_tmp = []; $count = count($data); //read in directories and files $dh = @opendir($base . '/' . $dir); if (!$dh) return; while (($file = readdir($dh)) !== false) { //skip hidden files and upper dirs if (preg_match('/^[._]/', $file)) continue; if (is_dir($base . '/' . $dir . '/' . $file)) { $dirs[] = $dir . '/' . $file; continue; } $files[] = $dir . '/' . $file; } closedir($dh); //Collect and sort files foreach ($files as $file) { call_user_func_array($func, [&$files_tmp, $base, $file, 'f', $lvl, $opts]); } usort($files_tmp, [$this, "compareNodes"]); //Collect and sort dirs if ($this->nsort) { //collect the wanted directories in dirs_tmp foreach ($dirs as $dir) { call_user_func_array($func, [&$dirs_tmp, $base, $dir, 'd', $lvl, $opts]); } //combine directories and pages and sort together $dirsAndFiles = array_merge($dirs_tmp, $files_tmp); usort($dirsAndFiles, [$this, "compareNodes"]); //add and search each directory foreach ($dirsAndFiles as $dirOrFile) { $data[] = $dirOrFile; if ($dirOrFile['type'] != 'f' && $dirOrFile['shouldBeTraversed']) { $this->customSearch($data, $base, $func, $opts, $dirOrFile['file'], $lvl + 1); } } } else { //sort by directory name Sort::sort($dirs); //collect directories foreach ($dirs as $dir) { if (call_user_func_array($func, [&$data, $base, $dir, 'd', $lvl, $opts])) { $this->customSearch($data, $base, $func, $opts, $dir, $lvl + 1); } } } //count added items $added = count($data) - $count; if ($added === 0 && $files_tmp === []) { //remove empty directory again, only if it has not a headpage associated $lastItem = end($data); if (!$lastItem['hns']) { array_pop($data); } } elseif (!$this->nsort) { //add files to index $data = array_merge($data, $files_tmp); } } /** * Get namespace title, checking for headpages * * @param string $ns namespace * @param string $headpage comma-separated headpages options and headpages * @param string|false $hns reference pageid of headpage, false when not existing * @return string when headpage & heading on: title of headpage, otherwise: namespace name * * @author Samuele Tognini */ public static function getNamespaceTitle($ns, $headpage, &$hns) { global $conf; $hns = false; $title = noNS($ns); if (empty($headpage)) { return $title; } $hpOptions = explode(",", $headpage); foreach ($hpOptions as $hp) { switch ($hp) { case ":inside:": $page = $ns . ":" . noNS($ns); break; case ":same:": $page = $ns; break; //it's an inside start case ":start:": $page = ltrim($ns . ":" . $conf['start'], ":"); break; //inside pages default: if (!blank($hp)) { //empty setting results in empty string here $page = $ns . ":" . $hp; } } //check headpage if (@file_exists(wikiFN($page)) && auth_quickaclcheck($page) >= AUTH_READ) { if ($conf['useheading'] == 1 || $conf['useheading'] === 'navigation') { $title_tmp = p_get_first_heading($page, false); if (!is_null($title_tmp)) { $title = $title_tmp; } } $title = hsc($title); $hns = $page; //headpage found, exit for break; } } return $title; } /** * callback that sorts nodes * * @param array $a first node as array with 'sort' entry * @param array $b second node as array with 'sort' entry * @return int if less than zero 1st node is less than 2nd, otherwise equal respectively larger */ private function compareNodes($a, $b) { if ($this->rsort) { return Sort::strcmp($b['sort'], $a['sort']); } else { return Sort::strcmp($a['sort'], $b['sort']); } } /** * Add sort information to item. * * @param array $item * @return bool|int|mixed|string * * @author Samuele Tognini */ private function getSortValue($item) { global $conf; $sort = false; $page = false; if ($item['type'] == 'd' || $item['type'] == 'l') { //Fake order info when nsort is not requested if ($this->nsort) { $page = $item['hns']; } else { $sort = 0; } } if ($item['type'] == 'f') { $page = $item['id']; } if ($page) { if ($this->hsort && noNS($item['id']) == $conf['start']) { $sort = 1; } if ($this->msort) { $sort = p_get_metadata($page, $this->msort); } if (!$sort && $this->sort) { switch ($this->sort) { case 't': $sort = $item['title']; break; case 'd': $sort = @filectime(wikiFN($page)); break; } } } if ($sort === false) { $sort = noNS($item['id']); } return $sort; } /** * Guess based on first option of the headpage config setting (default :start: if enabled) the headpage of the node * * @param string $headpage config setting * @param string $ns namespace * @return string guessed headpage */ private function guessHeadpage(string $headpage, string $ns): string { global $conf; $hns = false; $hpOptions = explode(",", $headpage); foreach ($hpOptions as $hp) { switch ($hp) { case ":inside:": $hns = $ns . ":" . noNS($ns); break 2; case ":same:": $hns = $ns; break 2; //it's an inside start case ":start:": $hns = ltrim($ns . ":" . $conf['start'], ":"); break 2; //inside pages default: if (!blank($hp)) { $hns = $ns . ":" . $hp; break 2; } } } if ($hns === false) { //fallback to start if headpage setting was empty $hns = ltrim($ns . ":" . $conf['start'], ":"); } return $hns; } }