* */ use dokuwiki\File\PageResolver; use dokuwiki\Utf8\PhpString; if (!defined('DOKU_INC')) die('meh.'); define('CATLIST_DISPLAY_LIST', 1); define('CATLIST_DISPLAY_LINE', 2); define('CATLIST_NSLINK_AUTO', 0); define('CATLIST_NSLINK_NONE', 1); define('CATLIST_NSLINK_FORCE', 2); define('CATLIST_INDEX_START', 0); define('CATLIST_INDEX_OUTSIDE', 1); define('CATLIST_INDEX_INSIDE', 2); define('CATLIST_SORT_NONE', 0); define('CATLIST_SORT_ASCENDING', 1); define('CATLIST_SORT_DESCENDING', 2); class syntax_plugin_catlist extends DokuWiki_Syntax_Plugin { function connectTo ($aMode) { $this->Lexer->addSpecialPattern(']*>', $aMode, 'plugin_catlist'); } function getSort () { return 189; } function getType () { return 'substition'; } /*********************************************************************************************/ /************************************ directive ************************************/ function _checkOption(&$match, $option, &$varAffected, $valIfFound){ if (preg_match('/-'.$option.' /i', $match, $found)) { $varAffected = $valIfFound; $match = str_replace($found[0], '', $match); } } function _checkOptionParam(&$match, $option, &$varAffected, $varAssoc){ if (preg_match('/-'.$option.':('.implode('|',array_keys($varAssoc)).') /i', $match, $found)) { $varAffected = $varAssoc[$found[1]]; $match = str_replace($found[0], '', $match); } } function handle ($match, $state, $pos, Doku_Handler $handler) { global $conf; $_default_sort_map = array("none" => CATLIST_SORT_NONE, "ascending" => CATLIST_SORT_ASCENDING, "descending" => CATLIST_SORT_DESCENDING); $_index_priority_map = array("start" => CATLIST_INDEX_START, "outside" => CATLIST_INDEX_OUTSIDE, "inside" => CATLIST_INDEX_INSIDE); $data = array('displayType' => CATLIST_DISPLAY_LIST, 'nsInBold' => true, 'expand' => 6, 'exclupage' => array(), 'excluns' => array(), 'exclunsall' => array(), 'exclunspages' => array(), 'exclunsns' => array(), 'exclutype' => 'id', 'createPageButtonNs' => true, 'createPageButtonSubs' => false, 'pagename_sanitize' => (boolean)$this->getConf('pagename_sanitize'), 'head' => (boolean)$this->getConf('showhead'), 'headTitle' => NULL, 'smallHead' => false, 'linkStartHead' => true, 'hn' => 'h1', 'useheading' => (boolean)$this->getConf('useheading'), 'nsuseheading' => NULL, 'nsLinks' => CATLIST_NSLINK_AUTO, 'columns' => 0, 'maxdepth' => 0, 'sort_order' => $_default_sort_map[$this->getConf('default_sort')], 'sort_by_title' => false, 'sort_by_type' => false, 'sort_by_date' => false, 'sort_collator' => $this->getConf('sort_collator_locale'), 'hide_index' => (boolean)$this->getConf('hide_index'), 'index_priority' => array(), 'nocache' => (boolean)$this->getConf('nocache'), 'hide_nsnotr' => (boolean)$this->getConf('hide_acl_nsnotr'), 'show_pgnoread' => false, 'show_perms' => (boolean)$this->getConf('show_acl'), 'show_leading_ns' => (boolean)$this->getConf('show_leading_ns'), 'show_notfound_error' => true ); $index_priority = explode(',', $this->getConf('index_priority')); foreach ($index_priority as $index_type) { if (!array_key_exists($index_type, $_index_priority_map)) { msg("catlist: invalid index type in index_priority", -1); return false; } $data['index_priority'][] = $_index_priority_map[$index_type]; } $match = PhpString::substr($match, 9, -1) . ' '; // Display options $this->_checkOption($match, "displayList", $data['displayType'], CATLIST_DISPLAY_LIST); $this->_checkOption($match, "displayLine", $data['displayType'], CATLIST_DISPLAY_LINE); $this->_checkOption($match, "noNSInBold", $data['nsInBold'], false); if (preg_match("/-expandButton:([0-9]+)/i", $match, $found)) { $data['expand'] = intval($found[1]); $match = str_replace($found[0], '', $match); } $this->_checkOption($match, "noHeadTitle", $data['useheading'], false); $this->_checkOption($match, "forceHeadTitle", $data['useheading'], true); $data['nsuseheading'] = $data['useheading']; $this->_checkOption($match, "noNSHeadTitle", $data['nsuseheading'], false); $this->_checkOption($match, "hideNotFoundMsg", $data['show_notfound_error'], false); // Namespace options $this->_checkOption($match, "forceLinks", $data['nsLinks'], CATLIST_NSLINK_FORCE); // /!\ Deprecated $this->_checkOptionParam($match, "nsLinks", $data['nsLinks'], array( "none" => CATLIST_NSLINK_NONE, "auto" => CATLIST_NSLINK_AUTO, "force" => CATLIST_NSLINK_FORCE )); // Exclude options for ($found; preg_match("/-(exclu(page|ns|nsall|nspages|nsns)!?):\"([^\\/\"]+)\" /i", $match, $found); ) { $option = strtolower($found[1]); // is regex negated ? if (substr($option,-1) == "!") { $data[substr($option,0,-1)][] = array('regex' => $found[3], 'neg' => true); } else { $data[$option][] = array('regex' => $found[3], 'neg' => false); } $match = str_replace($found[0], '', $match); } for ($found; preg_match("/-(exclu(page|ns|nsall|nspages|nsns)) /i", $match, $found); ) { $data[strtolower($found[1])] = true; $match = str_replace($found[0], '', $match); } // Exclude type (exclude based on id, name, or title) $this->_checkOption($match, "excludeOnID", $data['exclutype'], 'id'); $this->_checkOption($match, "excludeOnName", $data['exclutype'], 'name'); $this->_checkOption($match, "excludeOnTitle", $data['exclutype'], 'title'); // Exclude page/namespace id list $data['excludelist'] = array(); for ($found; preg_match("/-exclude:\\{([^\\}]*)\\} /", $match, $found); ) { $list = explode(' ', $found[1]); $data['excludelist'] = array_merge($data['excludelist'], $list); $match = str_replace($found[0], '', $match); } // Max depth if (preg_match("/-maxDepth:([0-9]+)/i", $match, $found)) { $data['maxdepth'] = intval($found[1]); $match = str_replace($found[0], '', $match); } // Columns if (preg_match("/-columns:([0-9]+)/i", $match, $found)) { $data['columns'] = intval($found[1]); $match = str_replace($found[0], '', $match); } // Head options $this->_checkOption($match, "noHead", $data['head'], false); $this->_checkOption($match, "showHead", $data['head'], true); $this->_checkOption($match, "smallHead", $data['smallHead'], true); $this->_checkOption($match, "noLinkStartHead", $data['linkStartHead'], false); if (preg_match("/-(h[1-5])/i", $match, $found)) { $data['hn'] = $found[1]; $match = str_replace($found[0], '', $match); } if (preg_match("/-titleHead:\"([^\"]*)\"/i", $match, $found)) { $data['headTitle'] = $found[1]; $match = str_replace($found[0], '', $match); } // Create page button options $this->_checkOption($match, "noAddPageButton", $data['createPageButtonNs'], false); $this->_checkOption($match, "addPageButtonEach", $data['createPageButtonSubs'], true); // Sorting options $this->_checkOption($match, "sortAscending", $data['sort_order'], CATLIST_SORT_ASCENDING); $this->_checkOption($match, "sortDescending", $data['sort_order'], CATLIST_SORT_DESCENDING); $this->_checkOption($match, "sortByTitle", $data['sort_by_title'], true); $this->_checkOption($match, "sortByType", $data['sort_by_type'], true); $this->_checkOption($match, "sortByCreationDate", $data['sort_by_date'], 'created'); $this->_checkOption($match, "sortByModifDate", $data['sort_by_date'], 'modified'); // ACL options $this->_checkOption($match, "ACLshowPage", $data['show_pgnoread'], true); $this->_checkOption($match, "ACLhideNs", $data['hide_nsnotr'], true); // Remove other options and warn about for ($found; preg_match("/ (-.*)/", $match, $found); ) { msg(sprintf($this->getLang('unknownoption'), htmlspecialchars($found[1])), -1); $match = str_replace($found[0], '', $match); } // Looking for the wanted namespace. Now, only the wanted namespace remains in $match. Then clean the namespace id $ns = trim($match); if ((boolean)$this->getConf('nswildcards')) { global $ID; $parsepagetemplate_data = array('id' => $ID, 'tpl' => $ns, 'doreplace' => true); $ns = parsePageTemplate($parsepagetemplate_data); } if ($ns == '') $ns = '.'; // If there is nothing, we take the current namespace global $ID; if ($ns[0] == '.') $ns = getNS($ID).':'.$ns; // If it start with a '.', it is a relative path $split = explode(':', $ns); for ($i = 0; $i < count($split); $i++) { if ($split[$i] === '' || $split[$i] === '.') { array_splice($split, $i, 1); $i--; } else if ($split[$i] == '..') { if ($i != 0) { array_splice($split, $i-1, 2); $i -= 2; } else break; } } if (count($split) > 0 && $split[0] == '..') { // Path would be outside the 'pages' directory msg($this->getLang('outofpages'), -1); return false; } $data['ns'] = implode(':', $split); return $data; } /**************************************************************************************/ /************************************ Tree walking ************************************/ /* Utility function to check is a given page/namespace ($item) is excluded * based on the relevant list of blacklisting/whitelisting regexes $arrayRegex * ( array of array('regex'=>the_regex,'neg'=>false/true) ). The exclusion * is based on item title, full id or name ($exclutype). */ function _isExcluded ($item, $exclutype, $arrayRegex) { if ($arrayRegex === true) return true; global $conf; if ((strlen($conf['hidepages']) != 0) && preg_match('/'.$conf['hidepages'].'/i', $item['id'])) return true; foreach($arrayRegex as $regex) { if (!is_array($regex)) // temporary, for transitioning to v2021-07-21 $regex = array('regex' => $regex, 'neg' => false); $match = preg_match('/'.$regex['regex'].(($exclutype=='title')?'/':'/i'), $item[$exclutype]); if ($regex['neg']) { if ($match === 0) return true; } else { if ($match === 1) return true; } } return false; } function _getStartPage ($index_priority, $parid, $parpath, $name, $force) { $exists = false; if ($parid != '') $parid .= ':'; global $conf; $index_path_map = array( CATLIST_INDEX_START => $parpath.'/'.$name.'/'.$conf['start'].'.txt', CATLIST_INDEX_OUTSIDE => $parpath.'/'.$name.'.txt', CATLIST_INDEX_INSIDE => $parpath.'/'.$name.'/'.$name.'.txt' ); $index_id_map = array( CATLIST_INDEX_START => $parid .$name.':'.$conf['start'], CATLIST_INDEX_OUTSIDE => $parid .$name, CATLIST_INDEX_INSIDE => $parid .$name.':'.$name ); foreach ($index_priority as $index_type) { if (is_file($index_path_map[$index_type])) { $exists = true; return array(true, $index_id_map[$index_type], $index_path_map[$index_type]); } } if ($force && isset($index_priority[0])) return array(false, $index_id_map[0], null); else return array(false, false, null); // returns ($index_exists, $index_id, $index_filepath) } function _getMetadata ($id, $filepath) { $meta = p_get_metadata($id, $key='', $render=METADATA_RENDER_USING_SIMPLE_CACHE); if (!isset($meta['date']['modified'])) $meta['date']['modified'] = @filemtime($filepath); if (!isset($meta['contributor'])) $meta['contributor'] = $meta['creator']; return $meta; } /* Entry function for tree walking, called in render() * * $data contains the various options initialized and parsed in handle(), and will be passed along * the tree walking. Moreover, $data['tree'] is filled by the pages found by _walk_recurse(), and * will contain the full tree, minus the excluded pages (however, permissions are only evaluated at * rendering time) and up to the max depth. _walk() prepares and start the tree walking. */ function _walk (&$data) { global $conf; // Get the directory path from namespace id, and check if it exists $ns = $data['ns']; $path = str_replace(':', '/', $ns); $path = $conf['datadir'].'/'.utf8_encodeFN($path); if (!is_dir($path)) { if ($data['show_notfound_error']) msg(sprintf($this->getLang('dontexist'), $ns), -1); return false; } // Info on the main page (the "header" page) $id = $ns . ':'; $resolver = new PageResolver($id); $id = $resolver->resolveId($id); $main = array( 'id' => $id, 'exist' => page_exists($id), 'title' => NULL ); if ($data['headTitle'] !== NULL) $main['title'] = $data['headTitle']; else { if ($data['useheading'] && $main['exist']) $main['title'] = p_get_first_heading($main['id'], true); if (is_null($main['title'])) { $ex = explode(':', $ns); $main['title'] = end($ex); } } $data['main'] = $main; // Preparing other stuff if (!isset($data['sort_collator']) || $data['sort_collator'] == "") $data['sort_collator'] = NULL; else { $locale = $data['sort_collator']; $coll = collator_create($locale); if (!isset($coll)) { msg("catlist sorting: can't create Collator object: ".intl_get_error_message(), -1); $data['sort_collator'] = NULL; } else { $coll->setAttribute(Collator::CASE_FIRST, Collator::UPPER_FIRST); $coll->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); $data['sort_collator'] = $coll; } } // Start the recursion if (!isset($data['excludelist'])) // temporary, for transitioning to v2021-07-21 $data['excludelist'] = array(); $data['tree'] = array(); $data['index_pages'] = array( $main['id'] ); $this->_walk_recurse($data, $path, $ns, "", false, false, 1/*root depth is 1*/, $data['tree']/*root*/); return true; } /* Recursive function for tree walking. * * Scans the current namespace by looking directly at the filesystem directory * for .txt files (pages) and sub-directories (namespaces). Excludes pages/namespaces * based on the various exclusion options. The current/local directory path, namesapce * ID and relative namespace ID are respectively $path, $ns and $relns. * $data is described above. $data['tree'] is not modified directly, but only through * $_TREE which is the *local* tree view (ie. a reference of a $data['tree'] node) and * where found children are added. Optionally sorts this list of children. * The local tree depth is $depth. $excluPages, $excluNS are flags indicates if the * sub-pages/namespaces should be excluded. Fills $data['index_pages'] with all * namespace IDs where an index has been found. */ function _walk_recurse (&$data, $path, $ns, $relns, $excluPages, $excluNS, $depth, &$_TREE) { $scanDirs = @scandir($path, SCANDIR_SORT_NONE); if ($scanDirs === false) { msg("catlist: can't open directory of namespace ".$ns, -1); return; } foreach ($scanDirs as $file) { if ($file[0] == '.' || $file[0] == '_') continue; $name = utf8_decodeFN(str_replace('.txt', '', $file)); $id = ($ns == '') ? $name : $ns.':'.$name; $rel_id = ($relns == '') ? $name : $relns.':'.$name; $item = array('id' => $id, 'rel_id' => $rel_id, 'name' => $name, 'title' => NULL); // ID exclusion if (in_array($rel_id, $data['excludelist'])) continue; // It's a namespace if (is_dir($path.'/'.$file)) { // Index page of the namespace list($index_exists, $index_id, $index_filepath) = $this->_getStartPage($data['index_priority'], $ns, $path, $name, ($data['nsLinks']==CATLIST_NSLINK_FORCE)); if ($index_exists) $data['index_pages'][] = $index_id; // Exclusion if ($excluNS) continue; if ($this->_isExcluded($item, $data['exclutype'], $data['excluns'])) continue; // Namespace if ($index_exists) { $item['metadata'] = $this->_getMetadata($index_id, $index_filepath); if ($data['nsuseheading'] && isset($item['metadata']['title'])) $item['title'] = $item['metadata']['title']; } if (is_null($item['title'])) $item['title'] = $name; $item['linkdisp'] = ($index_exists && ($data['nsLinks']==CATLIST_NSLINK_AUTO)) || ($data['nsLinks']==CATLIST_NSLINK_FORCE); $item['linkid'] = $index_id; // Button $item['buttonid'] = $data['createPageButtonSubs'] ? $id.':' : NULL; // Recursion if wanted $item['_'] = array(); $okdepth = ($depth < $data['maxdepth']) || ($data['maxdepth'] == 0); $exclude_content = $this->_isExcluded($item, $data['exclutype'], $data['exclunsall']) || in_array($rel_id.':', $data['excludelist']); if (!$exclude_content && $okdepth) { $exclunspages = $this->_isExcluded($item, $data['exclutype'], $data['exclunspages']); $exclunsns = $this->_isExcluded($item, $data['exclutype'], $data['exclunsns']); $this->_walk_recurse($data, $path.'/'.$file, $id, $rel_id, $exclunspages, $exclunsns, $depth+1, $item['_']); } // Tree $_TREE[] = $item; } else // It's a page if (!$excluPages) { if (substr($file, -4) != ".txt") continue; // Page title $item['metadata'] = $this->_getMetadata($id, $file); if ($data['useheading'] && isset($item['metadata']['title'])) { $item['title'] = $item['metadata']['title']; } if (is_null($item['title'])) $item['title'] = $name; // Exclusion if ($this->_isExcluded($item, $data['exclutype'], $data['exclupage'])) continue; // Tree $_TREE[] = $item; } // Sorting if ($data['sort_order'] != CATLIST_SORT_NONE) { usort($_TREE, function ($a, $b) use ($data) { $a_is_folder = isset($a['_']); $b_is_folder = isset($b['_']); // if one or the other is folder, comparison is done if ($data['sort_by_type'] && ($a_is_folder xor $b_is_folder )) return $b_is_folder; // else, compare date or name if ($data['sort_by_date'] === false) { // by name $a_title = ($data['sort_by_title'] ? $a['title'] : $a['name']); $b_title = ($data['sort_by_title'] ? $b['title'] : $b['name']); if (!is_null($data['sort_collator'])) $r = $data['sort_collator']->compare($a_title, $b_title); else $r = strnatcasecmp($a_title, $b_title); } else { // by date $field = $data['sort_by_date']; $a_date = (isset($a['metadata']['date'][$field]) ? $a['metadata']['date'][$field] : 0); $b_date = (isset($b['metadata']['date'][$field]) ? $b['metadata']['date'][$field] : 0); $r = $a_date <=> $b_date; } if ($data['sort_order'] == CATLIST_SORT_DESCENDING) $r *= -1; return $r; }); } } } /***********************************************************************************/ /************************************ Rendering ************************************/ function render ($mode, Doku_Renderer $renderer, $data) { if (!is_array($data)) return false; if ($data['ns'] == '%%CURRENT_NAMESPACE%%') $data['ns'] = getNS(cleanID(getID())); // update namespace to the one currently displayed $ns = $data['ns']; // Disabling cache if ($data['nocache']) $renderer->nocache(); // Walk namespace tree $r = $this->_walk($data); if ($r == false) return false; // Write params for the add page button global $conf; if (!isset($data['pagename_sanitize'])) // temporary, for transitioning to v2022-06-25 $data['pagename_sanitize'] = true; $renderer->doc .= ''; // Display headline if ($data['head']) { $html_tag_small = ($data['nsInBold']) ? 'strong' : 'span'; $html_tag = ($data['smallHead']) ? $html_tag_small : $data['hn']; $renderer->doc .= '<'.$html_tag.' class="catlist-head">'; $main = $data['main']; if (($main['exist'] && $data['linkStartHead'] && !($data['nsLinks']==CATLIST_NSLINK_NONE)) || ($data['nsLinks']==CATLIST_NSLINK_FORCE)) $renderer->internallink(':'.$main['id'], $main['title']); else $renderer->doc .= htmlspecialchars($main['title']); $renderer->doc .= ''; } // Recurse and display $global_ul_attr = ""; if ($data['columns'] != 0) { $global_ul_attr = 'column-count: '.$data['columns'].';'; $global_ul_attr = 'style="-webkit-'.$global_ul_attr.' -moz-'.$global_ul_attr.' '.$global_ul_attr.'" '; $global_ul_attr .= 'class="catlist_columns catlist-nslist" '; } else { $global_ul_attr = 'class="catlist-nslist" '; } if ($data['displayType'] == CATLIST_DISPLAY_LIST) $renderer->doc .= ''; return true; } /* Just cache the calls to auth_quickaclcheck, mainly for _any_child_perms */ function _cached_quickaclcheck($id) { static $cache = array(); if (!isset($cache[$id])) $cache[$id] = auth_quickaclcheck($id); return $cache[$id]; } /* Walk the tree to see if any page/namespace below this has read access access, for show_leading_ns option */ function _any_child_perms ($data, $_TREE) { foreach ($_TREE as $item) { if (isset($item['_'])) { $perms = $this->_cached_quickaclcheck($item['id'].':*'); if ($perms >= AUTH_READ || $this->_any_child_perms($data, $item['_'])) return true; } else { $perms = $this->_cached_quickaclcheck($item['id']); if ($perms >= AUTH_READ) return true; } } return false; } function _recurse (&$renderer, $data, $_TREE) { foreach ($_TREE as $item) { if (isset($item['_'])) { // It's a namespace $perms = $this->_cached_quickaclcheck($item['id'].':*'); $perms_exemption = $data['show_perms']; // If we actually care about not showing the namespace because of permissions : if ($perms < AUTH_READ && !$perms_exemption) { // If show_leading_ns activated, walk the tree below this, see if any page/namespace below this has access if ($data['show_leading_ns'] && $this->_any_child_perms($data, $item['_'])) { $perms_exemption = true; } else { if ($data['hide_nsnotr']) continue; if ($data['show_pgnoread']) $perms_exemption = true; // Add exception if show_pgnoread enabled, but hide_nsnotr prevails } } $linkdisp = $item['linkdisp'] && ($perms >= AUTH_READ); if ($perms < AUTH_CREATE) $item['buttonid'] = NULL; $this->_displayNSBegin($renderer, $data, $item['title'], $linkdisp, $item['linkid'], ($data['show_perms'] ? $perms : NULL)); if ($perms >= AUTH_READ || $perms_exemption) $this->_recurse($renderer, $data, $item['_']); $this->_displayNSEnd($renderer, $data['displayType'], $item['buttonid']); } else { // It's a page $perms = $this->_cached_quickaclcheck($item['id']); if ($perms < AUTH_READ && !$data['show_perms'] && !$data['show_pgnoread']) continue; if ($data['hide_index'] && in_array($item['id'], $data['index_pages'])) continue; $displayLink = $perms >= AUTH_READ || $data['show_perms']; $this->_displayPage($renderer, $item, $data['displayType'], ($data['show_perms'] ? $perms : NULL), $displayLink); } } } function _displayNSBegin (&$renderer, $data, $title, $displayLink, $idLink, $perms) { if ($data['displayType'] == CATLIST_DISPLAY_LIST) { $warper_ns = ($data['nsInBold']) ? 'strong' : 'span'; $renderer->doc .= '
  • <'.$warper_ns.' class="li catlist-nshead">'; if ($displayLink) $renderer->internallink($idLink, $title); else $renderer->doc .= htmlspecialchars($title); if ($perms !== NULL) $renderer->doc .= ' [ns, perm='.$perms.']'; $renderer->doc .= ''; $renderer->doc .= '
      '; } else if ($data['displayType'] == CATLIST_DISPLAY_LINE) { if ($data['nsInBold']) $renderer->doc .= ''; if ($displayLink) $renderer->internallink($idLink, $title); else $renderer->doc .= htmlspecialchars($title); if ($data['nsInBold']) $renderer->doc .= ''; $renderer->doc .= '[ '; } } function _displayNSEnd (&$renderer, $displayType, $nsAddButton) { if (!is_null($nsAddButton)) $this->_displayAddPageButton($renderer, $nsAddButton, $displayType); if ($displayType == CATLIST_DISPLAY_LIST) $renderer->doc .= '
  • '; else if ($displayType == CATLIST_DISPLAY_LINE) $renderer->doc .= '] '; } function _displayPage (&$renderer, $item, $displayType, $perms, $displayLink) { if ($displayType == CATLIST_DISPLAY_LIST) { $renderer->doc .= '
  • '; if ($displayLink) $renderer->internallink(':'.$item['id'], $item['title']); else $renderer->doc .= htmlspecialchars($item['title']); if ($perms !== NULL) $renderer->doc .= ' [page, perm='.$perms.']'; $renderer->doc .= '
  • '; } else if ($displayType == CATLIST_DISPLAY_LINE) { $renderer->internallink(':'.$item['id'], $item['title']); $renderer->doc .= ' '; } } function _displayAddPageButton (&$renderer, $ns, $displayType) { $html = ($displayType == CATLIST_DISPLAY_LIST) ? 'li' : 'span'; $renderer->doc .= '<'.$html.' class="catlist_addpage">'; } }