1*9fd890c3Stracker-user<?php 2*9fd890c3Stracker-user 3*9fd890c3Stracker-user/** 4*9fd890c3Stracker-user * Annotations plugin — admin overview. 5*9fd890c3Stracker-user * 6*9fd890c3Stracker-user * Lists every page that has stored annotations, with its normal (present) and 7*9fd890c3Stracker-user * orphaned counts, and lets an admin clear orphaned annotations for one page or 8*9fd890c3Stracker-user * for every annotated page at once. 9*9fd890c3Stracker-user * 10*9fd890c3Stracker-user * The table reuses the JS-free pattern shared by the lastseen and usersettings 11*9fd890c3Stracker-user * admin panels: sortable column headers, a per-column text filter (only the 12*9fd890c3Stracker-user * Page column is filterable here — the count columns and the actions column are 13*9fd890c3Stracker-user * not), and a numbered pager. The clear operations are POST forms guarded by a 14*9fd890c3Stracker-user * CSRF token, with Post/Redirect/Get back to the overview. 15*9fd890c3Stracker-user * 16*9fd890c3Stracker-user * Permission: admin only (forAdminOnly), matching the helper's canClear() rule. 17*9fd890c3Stracker-user * The counts are computed by helper::pageCounts(), which renders each page to 18*9fd890c3Stracker-user * detect orphans (cached by p_wiki_xhtml); see DESIGN.md for the cost note. 19*9fd890c3Stracker-user */ 20*9fd890c3Stracker-user 21*9fd890c3Stracker-user// must be run within DokuWiki 22*9fd890c3Stracker-userif (!defined('DOKU_INC')) die(); 23*9fd890c3Stracker-user 24*9fd890c3Stracker-useruse dokuwiki\Extension\AdminPlugin; 25*9fd890c3Stracker-useruse dokuwiki\Utf8\PhpString; 26*9fd890c3Stracker-user 27*9fd890c3Stracker-userclass admin_plugin_annotations extends AdminPlugin 28*9fd890c3Stracker-user{ 29*9fd890c3Stracker-user /** @var string[] columns that may be sorted */ 30*9fd890c3Stracker-user protected $sortable = ['page', 'normal', 'orphaned']; 31*9fd890c3Stracker-user 32*9fd890c3Stracker-user /** 33*9fd890c3Stracker-user * Admin only — clearing annotations is an admin operation (helper::canClear). 34*9fd890c3Stracker-user * 35*9fd890c3Stracker-user * @return bool 36*9fd890c3Stracker-user */ 37*9fd890c3Stracker-user public function forAdminOnly() 38*9fd890c3Stracker-user { 39*9fd890c3Stracker-user return true; 40*9fd890c3Stracker-user } 41*9fd890c3Stracker-user 42*9fd890c3Stracker-user /** 43*9fd890c3Stracker-user * Position in the admin menu. 44*9fd890c3Stracker-user * 45*9fd890c3Stracker-user * @return int 46*9fd890c3Stracker-user */ 47*9fd890c3Stracker-user public function getMenuSort() 48*9fd890c3Stracker-user { 49*9fd890c3Stracker-user return 1000; 50*9fd890c3Stracker-user } 51*9fd890c3Stracker-user 52*9fd890c3Stracker-user /** 53*9fd890c3Stracker-user * Admin-menu label. 54*9fd890c3Stracker-user * 55*9fd890c3Stracker-user * @param string $language 56*9fd890c3Stracker-user * @return string 57*9fd890c3Stracker-user */ 58*9fd890c3Stracker-user public function getMenuText($language) 59*9fd890c3Stracker-user { 60*9fd890c3Stracker-user return $this->getLang('menu'); 61*9fd890c3Stracker-user } 62*9fd890c3Stracker-user 63*9fd890c3Stracker-user // --------------------------------------------------------------------- 64*9fd890c3Stracker-user // Request handling (clear actions) 65*9fd890c3Stracker-user // --------------------------------------------------------------------- 66*9fd890c3Stracker-user 67*9fd890c3Stracker-user /** 68*9fd890c3Stracker-user * Process a submitted clear action, then Post/Redirect/Get back to the 69*9fd890c3Stracker-user * overview so a reload does not repeat it. DokuWiki's admin dispatcher 70*9fd890c3Stracker-user * enforces forAdminOnly() before this runs; the CSRF token and the helper's 71*9fd890c3Stracker-user * canClear() rule are still checked. 72*9fd890c3Stracker-user * 73*9fd890c3Stracker-user * @return void 74*9fd890c3Stracker-user */ 75*9fd890c3Stracker-user public function handle() 76*9fd890c3Stracker-user { 77*9fd890c3Stracker-user global $INPUT, $ID; 78*9fd890c3Stracker-user 79*9fd890c3Stracker-user $action = $INPUT->post->str('annotations_action'); 80*9fd890c3Stracker-user if ($action === '') { 81*9fd890c3Stracker-user return; 82*9fd890c3Stracker-user } 83*9fd890c3Stracker-user if (!checkSecurityToken()) { 84*9fd890c3Stracker-user return; 85*9fd890c3Stracker-user } 86*9fd890c3Stracker-user 87*9fd890c3Stracker-user /** @var helper_plugin_annotations $helper */ 88*9fd890c3Stracker-user $helper = $this->loadHelper('annotations', false); 89*9fd890c3Stracker-user if (!$helper || !$helper->canClear(auth_isadmin())) { 90*9fd890c3Stracker-user return; 91*9fd890c3Stracker-user } 92*9fd890c3Stracker-user 93*9fd890c3Stracker-user if ($action === 'clear_orphaned') { 94*9fd890c3Stracker-user $page = cleanID($INPUT->post->str('clearpage')); 95*9fd890c3Stracker-user if ($page !== '') { 96*9fd890c3Stracker-user $count = $helper->clearOrphaned($page); 97*9fd890c3Stracker-user if ($count === false) { 98*9fd890c3Stracker-user msg($this->getLang('clear_fail'), -1); 99*9fd890c3Stracker-user } else { 100*9fd890c3Stracker-user msg(sprintf($this->getLang('cleared_page'), $count, hsc($page)), 1); 101*9fd890c3Stracker-user } 102*9fd890c3Stracker-user } 103*9fd890c3Stracker-user } elseif ($action === 'clear_orphaned_all') { 104*9fd890c3Stracker-user $count = $helper->clearOrphanedAll(); 105*9fd890c3Stracker-user msg(sprintf($this->getLang('cleared_all'), $count), 1); 106*9fd890c3Stracker-user } 107*9fd890c3Stracker-user 108*9fd890c3Stracker-user send_redirect(wl($ID, $this->standingParams(), true, '&')); 109*9fd890c3Stracker-user } 110*9fd890c3Stracker-user 111*9fd890c3Stracker-user // --------------------------------------------------------------------- 112*9fd890c3Stracker-user // Output 113*9fd890c3Stracker-user // --------------------------------------------------------------------- 114*9fd890c3Stracker-user 115*9fd890c3Stracker-user /** 116*9fd890c3Stracker-user * Render the overview: a wiki-wide "clear all orphaned" button, then a 117*9fd890c3Stracker-user * sortable/filterable/paginated table of annotated pages. 118*9fd890c3Stracker-user * 119*9fd890c3Stracker-user * @return void 120*9fd890c3Stracker-user */ 121*9fd890c3Stracker-user public function html() 122*9fd890c3Stracker-user { 123*9fd890c3Stracker-user global $INPUT, $ID; 124*9fd890c3Stracker-user 125*9fd890c3Stracker-user echo '<div class="plugin_annotations_admin">'; 126*9fd890c3Stracker-user echo '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 127*9fd890c3Stracker-user 128*9fd890c3Stracker-user /** @var helper_plugin_annotations $helper */ 129*9fd890c3Stracker-user $helper = $this->loadHelper('annotations', false); 130*9fd890c3Stracker-user if (!$helper) { 131*9fd890c3Stracker-user echo '<div class="error">' . hsc($this->getLang('helper_missing')) . '</div></div>'; 132*9fd890c3Stracker-user return; 133*9fd890c3Stracker-user } 134*9fd890c3Stracker-user 135*9fd890c3Stracker-user // Build every row up front: the counts feed sorting, filtering and the 136*9fd890c3Stracker-user // wiki-wide orphan total, so all annotated pages are processed here. 137*9fd890c3Stracker-user $rows = $this->buildRows($helper); 138*9fd890c3Stracker-user if ($rows === []) { 139*9fd890c3Stracker-user echo '<p class="annotations_admin_none">' . hsc($this->getLang('none')) . '</p>'; 140*9fd890c3Stracker-user echo '</div>'; 141*9fd890c3Stracker-user return; 142*9fd890c3Stracker-user } 143*9fd890c3Stracker-user 144*9fd890c3Stracker-user $totalOrphaned = 0; 145*9fd890c3Stracker-user foreach ($rows as $r) { 146*9fd890c3Stracker-user $totalOrphaned += $r['orphaned']; 147*9fd890c3Stracker-user } 148*9fd890c3Stracker-user 149*9fd890c3Stracker-user // request parameters 150*9fd890c3Stracker-user $sort = $INPUT->str('sort', 'page'); 151*9fd890c3Stracker-user if (!in_array($sort, $this->sortable, true)) { 152*9fd890c3Stracker-user $sort = 'page'; 153*9fd890c3Stracker-user } 154*9fd890c3Stracker-user $dir = ($INPUT->str('dir') === 'desc') ? 'desc' : 'asc'; 155*9fd890c3Stracker-user $filters = $this->activeFilters(); 156*9fd890c3Stracker-user 157*9fd890c3Stracker-user $shown = $this->applyFilters($rows, $filters); 158*9fd890c3Stracker-user $shown = $this->sortRows($shown, $sort, $dir); 159*9fd890c3Stracker-user $total = count($shown); 160*9fd890c3Stracker-user 161*9fd890c3Stracker-user [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($shown, (int) $this->getConf('entries_per_page')); 162*9fd890c3Stracker-user 163*9fd890c3Stracker-user echo '<p>' . hsc($this->getLang('intro')) . '</p>'; 164*9fd890c3Stracker-user 165*9fd890c3Stracker-user // wiki-wide clear-all (counts all annotated pages, not just the filtered 166*9fd890c3Stracker-user // view); the button targets the POST form rendered at the end via form= 167*9fd890c3Stracker-user echo '<div class="annotations_admin_bar">'; 168*9fd890c3Stracker-user if ($totalOrphaned > 0) { 169*9fd890c3Stracker-user echo '<button type="submit" form="ann_clear_all" class="button"' 170*9fd890c3Stracker-user . $this->confirmAttr('confirm_clear_all') . '>' 171*9fd890c3Stracker-user . hsc(sprintf($this->getLang('btn_clear_all'), $totalOrphaned)) . '</button>'; 172*9fd890c3Stracker-user } 173*9fd890c3Stracker-user echo '</div>'; 174*9fd890c3Stracker-user 175*9fd890c3Stracker-user $cols = ['page', 'normal', 'orphaned', 'actions']; 176*9fd890c3Stracker-user $labels = [ 177*9fd890c3Stracker-user 'page' => $this->getLang('th_page'), 178*9fd890c3Stracker-user 'normal' => $this->getLang('th_normal'), 179*9fd890c3Stracker-user 'orphaned' => $this->getLang('th_orphaned'), 180*9fd890c3Stracker-user 'actions' => $this->getLang('th_actions'), 181*9fd890c3Stracker-user ]; 182*9fd890c3Stracker-user 183*9fd890c3Stracker-user // GET form so the filter combines with the sort links and bookmarks 184*9fd890c3Stracker-user // cleanly; the action URL's query string is dropped on submit, so the 185*9fd890c3Stracker-user // standing parameters travel as hidden fields. 186*9fd890c3Stracker-user echo '<form class="annotations_admin_filter" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 187*9fd890c3Stracker-user echo '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 188*9fd890c3Stracker-user echo '<input type="hidden" name="do" value="admin" />'; 189*9fd890c3Stracker-user echo '<input type="hidden" name="page" value="annotations" />'; 190*9fd890c3Stracker-user echo '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 191*9fd890c3Stracker-user echo '<input type="hidden" name="dir" value="' . hsc($dir) . '" />'; 192*9fd890c3Stracker-user echo '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1 193*9fd890c3Stracker-user 194*9fd890c3Stracker-user echo '<div class="table">'; 195*9fd890c3Stracker-user echo '<table class="inline plugin_annotations_admin">'; 196*9fd890c3Stracker-user echo '<thead><tr>'; 197*9fd890c3Stracker-user foreach ($cols as $c) { 198*9fd890c3Stracker-user echo $this->headerCell($c, $labels[$c], $sort, $dir); 199*9fd890c3Stracker-user } 200*9fd890c3Stracker-user echo '</tr>'; 201*9fd890c3Stracker-user echo $this->renderFilterRow($cols, $filters, $sort, $dir); 202*9fd890c3Stracker-user echo '</thead><tbody>'; 203*9fd890c3Stracker-user 204*9fd890c3Stracker-user if ($total === 0) { 205*9fd890c3Stracker-user echo '<tr><td colspan="' . count($cols) . '" class="annotations_admin_none">' 206*9fd890c3Stracker-user . hsc($this->getLang('none_match')) . '</td></tr>'; 207*9fd890c3Stracker-user } else { 208*9fd890c3Stracker-user foreach ($pageRows as $row) { 209*9fd890c3Stracker-user echo $this->renderRow($row); 210*9fd890c3Stracker-user } 211*9fd890c3Stracker-user } 212*9fd890c3Stracker-user 213*9fd890c3Stracker-user echo '</tbody></table></div>'; 214*9fd890c3Stracker-user echo '</form>'; 215*9fd890c3Stracker-user 216*9fd890c3Stracker-user echo $this->renderPager($page, $totalPages); 217*9fd890c3Stracker-user 218*9fd890c3Stracker-user if ($total > 0) { 219*9fd890c3Stracker-user echo '<p class="annotations_admin_count">' 220*9fd890c3Stracker-user . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>'; 221*9fd890c3Stracker-user } 222*9fd890c3Stracker-user 223*9fd890c3Stracker-user // POST forms targeted by the clear buttons (siblings of the GET form, so 224*9fd890c3Stracker-user // no illegal nested <form>; buttons reach them via the HTML5 form= attr) 225*9fd890c3Stracker-user echo $this->clearForms(); 226*9fd890c3Stracker-user 227*9fd890c3Stracker-user echo '</div>'; 228*9fd890c3Stracker-user } 229*9fd890c3Stracker-user 230*9fd890c3Stracker-user /** 231*9fd890c3Stracker-user * One table row per annotated page: counts plus, when a page has orphans, a 232*9fd890c3Stracker-user * per-page "clear orphaned" submit button. 233*9fd890c3Stracker-user * 234*9fd890c3Stracker-user * @param array $row ['id','title','normal','orphaned'] 235*9fd890c3Stracker-user * @return string 236*9fd890c3Stracker-user */ 237*9fd890c3Stracker-user protected function renderRow(array $row) 238*9fd890c3Stracker-user { 239*9fd890c3Stracker-user $html = '<tr>'; 240*9fd890c3Stracker-user 241*9fd890c3Stracker-user // Page: title as a link to the page; the id as muted secondary text 242*9fd890c3Stracker-user // (omitted when the title is just the id, i.e. the page has no heading). 243*9fd890c3Stracker-user $html .= '<td class="annotations_admin_page">'; 244*9fd890c3Stracker-user $html .= '<a href="' . wl($row['id']) . '">' . hsc($row['title']) . '</a>'; 245*9fd890c3Stracker-user if ($row['title'] !== $row['id']) { 246*9fd890c3Stracker-user $html .= ' <span class="annotations_admin_id">' . hsc($row['id']) . '</span>'; 247*9fd890c3Stracker-user } 248*9fd890c3Stracker-user $html .= '</td>'; 249*9fd890c3Stracker-user 250*9fd890c3Stracker-user $html .= '<td class="annotations_admin_num">' . ((int) $row['normal']) . '</td>'; 251*9fd890c3Stracker-user $html .= '<td class="annotations_admin_num">' . ((int) $row['orphaned']) . '</td>'; 252*9fd890c3Stracker-user 253*9fd890c3Stracker-user $html .= '<td class="annotations_admin_actions">'; 254*9fd890c3Stracker-user if ($row['orphaned'] > 0) { 255*9fd890c3Stracker-user $html .= '<button type="submit" form="ann_clear_single" class="button"' 256*9fd890c3Stracker-user . ' name="clearpage" value="' . hsc($row['id']) . '"' 257*9fd890c3Stracker-user . $this->confirmAttr('confirm_clear_page') . '>' 258*9fd890c3Stracker-user . hsc($this->getLang('btn_clear_orphaned')) . '</button>'; 259*9fd890c3Stracker-user } 260*9fd890c3Stracker-user $html .= '</td>'; 261*9fd890c3Stracker-user 262*9fd890c3Stracker-user return $html . '</tr>'; 263*9fd890c3Stracker-user } 264*9fd890c3Stracker-user 265*9fd890c3Stracker-user /** 266*9fd890c3Stracker-user * Build one row per annotated page. Pages whose annotation file is present 267*9fd890c3Stracker-user * but empty are skipped (helper::getAnnotatedPages already filters those, 268*9fd890c3Stracker-user * but the count is re-checked here too). 269*9fd890c3Stracker-user * 270*9fd890c3Stracker-user * @param helper_plugin_annotations $helper 271*9fd890c3Stracker-user * @return array list of ['id','title','normal','orphaned'] 272*9fd890c3Stracker-user */ 273*9fd890c3Stracker-user protected function buildRows($helper) 274*9fd890c3Stracker-user { 275*9fd890c3Stracker-user $rows = []; 276*9fd890c3Stracker-user foreach ($helper->getAnnotatedPages() as $id) { 277*9fd890c3Stracker-user $counts = $helper->pageCounts($id); 278*9fd890c3Stracker-user if ($counts['total'] === 0) { 279*9fd890c3Stracker-user continue; 280*9fd890c3Stracker-user } 281*9fd890c3Stracker-user $title = p_get_first_heading($id); 282*9fd890c3Stracker-user if (!is_string($title) || $title === '') { 283*9fd890c3Stracker-user $title = $id; 284*9fd890c3Stracker-user } 285*9fd890c3Stracker-user $rows[] = [ 286*9fd890c3Stracker-user 'id' => $id, 287*9fd890c3Stracker-user 'title' => $title, 288*9fd890c3Stracker-user 'normal' => $counts['normal'], 289*9fd890c3Stracker-user 'orphaned' => $counts['orphaned'], 290*9fd890c3Stracker-user ]; 291*9fd890c3Stracker-user } 292*9fd890c3Stracker-user return $rows; 293*9fd890c3Stracker-user } 294*9fd890c3Stracker-user 295*9fd890c3Stracker-user // --------------------------------------------------------------------- 296*9fd890c3Stracker-user // Filtering & sorting 297*9fd890c3Stracker-user // --------------------------------------------------------------------- 298*9fd890c3Stracker-user 299*9fd890c3Stracker-user /** 300*9fd890c3Stracker-user * The active page filter, read from the q[] array (only the Page column is 301*9fd890c3Stracker-user * filterable). Returns [] or ['page' => term]. 302*9fd890c3Stracker-user * 303*9fd890c3Stracker-user * @return array 304*9fd890c3Stracker-user */ 305*9fd890c3Stracker-user protected function activeFilters() 306*9fd890c3Stracker-user { 307*9fd890c3Stracker-user global $INPUT; 308*9fd890c3Stracker-user $raw = $INPUT->arr('q'); 309*9fd890c3Stracker-user if (isset($raw['page']) && is_string($raw['page'])) { 310*9fd890c3Stracker-user $term = trim($raw['page']); 311*9fd890c3Stracker-user if ($term !== '') { 312*9fd890c3Stracker-user return ['page' => $term]; 313*9fd890c3Stracker-user } 314*9fd890c3Stracker-user } 315*9fd890c3Stracker-user return []; 316*9fd890c3Stracker-user } 317*9fd890c3Stracker-user 318*9fd890c3Stracker-user /** 319*9fd890c3Stracker-user * Keep only rows whose title OR id matches the page filter (substring, 320*9fd890c3Stracker-user * case-insensitive). 321*9fd890c3Stracker-user * 322*9fd890c3Stracker-user * @param array $rows 323*9fd890c3Stracker-user * @param array $filters 324*9fd890c3Stracker-user * @return array 325*9fd890c3Stracker-user */ 326*9fd890c3Stracker-user protected function applyFilters(array $rows, array $filters) 327*9fd890c3Stracker-user { 328*9fd890c3Stracker-user if (!isset($filters['page'])) { 329*9fd890c3Stracker-user return $rows; 330*9fd890c3Stracker-user } 331*9fd890c3Stracker-user $term = $filters['page']; 332*9fd890c3Stracker-user return array_values(array_filter($rows, function ($row) use ($term) { 333*9fd890c3Stracker-user return $this->matches($row['title'], $term) || $this->matches($row['id'], $term); 334*9fd890c3Stracker-user })); 335*9fd890c3Stracker-user } 336*9fd890c3Stracker-user 337*9fd890c3Stracker-user /** 338*9fd890c3Stracker-user * Case-insensitive UTF-8 substring test. 339*9fd890c3Stracker-user * 340*9fd890c3Stracker-user * @param string $haystack 341*9fd890c3Stracker-user * @param string $needle 342*9fd890c3Stracker-user * @return bool 343*9fd890c3Stracker-user */ 344*9fd890c3Stracker-user protected function matches($haystack, $needle) 345*9fd890c3Stracker-user { 346*9fd890c3Stracker-user if ($needle === '') { 347*9fd890c3Stracker-user return true; 348*9fd890c3Stracker-user } 349*9fd890c3Stracker-user $h = PhpString::strtolower((string) $haystack); 350*9fd890c3Stracker-user $n = PhpString::strtolower((string) $needle); 351*9fd890c3Stracker-user return PhpString::strpos($h, $n) !== false; 352*9fd890c3Stracker-user } 353*9fd890c3Stracker-user 354*9fd890c3Stracker-user /** 355*9fd890c3Stracker-user * Sort rows by the given column and direction. The page column sorts by 356*9fd890c3Stracker-user * title (case-insensitive); the count columns sort numerically. The page id 357*9fd890c3Stracker-user * is a stable tiebreak in every case. 358*9fd890c3Stracker-user * 359*9fd890c3Stracker-user * @param array $rows 360*9fd890c3Stracker-user * @param string $sort one of $this->sortable 361*9fd890c3Stracker-user * @param string $dir 'asc' or 'desc' 362*9fd890c3Stracker-user * @return array 363*9fd890c3Stracker-user */ 364*9fd890c3Stracker-user protected function sortRows(array $rows, $sort, $dir) 365*9fd890c3Stracker-user { 366*9fd890c3Stracker-user usort($rows, static function ($a, $b) use ($sort) { 367*9fd890c3Stracker-user if ($sort === 'normal' || $sort === 'orphaned') { 368*9fd890c3Stracker-user $cmp = $a[$sort] <=> $b[$sort]; 369*9fd890c3Stracker-user } else { 370*9fd890c3Stracker-user $cmp = strcasecmp((string) $a['title'], (string) $b['title']); 371*9fd890c3Stracker-user } 372*9fd890c3Stracker-user if ($cmp === 0) { 373*9fd890c3Stracker-user $cmp = strcasecmp((string) $a['id'], (string) $b['id']); 374*9fd890c3Stracker-user } 375*9fd890c3Stracker-user return $cmp; 376*9fd890c3Stracker-user }); 377*9fd890c3Stracker-user if ($dir === 'desc') { 378*9fd890c3Stracker-user $rows = array_reverse($rows); 379*9fd890c3Stracker-user } 380*9fd890c3Stracker-user return $rows; 381*9fd890c3Stracker-user } 382*9fd890c3Stracker-user 383*9fd890c3Stracker-user /** 384*9fd890c3Stracker-user * Slice the rows for the current page. 385*9fd890c3Stracker-user * 386*9fd890c3Stracker-user * @param array $rows filtered + sorted rows 387*9fd890c3Stracker-user * @param int $perPage rows per page; <= 0 means "all on one page" 388*9fd890c3Stracker-user * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based 389*9fd890c3Stracker-user * row numbers of the slice (0 when there are no rows) 390*9fd890c3Stracker-user */ 391*9fd890c3Stracker-user protected function paginate(array $rows, $perPage) 392*9fd890c3Stracker-user { 393*9fd890c3Stracker-user global $INPUT; 394*9fd890c3Stracker-user $total = count($rows); 395*9fd890c3Stracker-user 396*9fd890c3Stracker-user if ($perPage <= 0) { 397*9fd890c3Stracker-user return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; 398*9fd890c3Stracker-user } 399*9fd890c3Stracker-user 400*9fd890c3Stracker-user $totalPages = max(1, (int) ceil($total / $perPage)); 401*9fd890c3Stracker-user $page = $INPUT->int('pg', 1); 402*9fd890c3Stracker-user if ($page < 1) { 403*9fd890c3Stracker-user $page = 1; 404*9fd890c3Stracker-user } 405*9fd890c3Stracker-user if ($page > $totalPages) { 406*9fd890c3Stracker-user $page = $totalPages; 407*9fd890c3Stracker-user } 408*9fd890c3Stracker-user 409*9fd890c3Stracker-user $offset = ($page - 1) * $perPage; 410*9fd890c3Stracker-user $slice = array_slice($rows, $offset, $perPage); 411*9fd890c3Stracker-user $from = $total > 0 ? $offset + 1 : 0; 412*9fd890c3Stracker-user $to = min($total, $offset + $perPage); 413*9fd890c3Stracker-user 414*9fd890c3Stracker-user return [$slice, $page, $totalPages, $from, $to]; 415*9fd890c3Stracker-user } 416*9fd890c3Stracker-user 417*9fd890c3Stracker-user // --------------------------------------------------------------------- 418*9fd890c3Stracker-user // Link / header / filter-row / pager helpers 419*9fd890c3Stracker-user // --------------------------------------------------------------------- 420*9fd890c3Stracker-user 421*9fd890c3Stracker-user /** 422*9fd890c3Stracker-user * The standing query parameters every in-table link and the clear-action 423*9fd890c3Stracker-user * redirect must carry: the admin page id, the active sort + direction, the 424*9fd890c3Stracker-user * page filter and the current page number. Read from $INPUT (which merges 425*9fd890c3Stracker-user * GET and POST) so it works for the GET table links and the POST clear 426*9fd890c3Stracker-user * forms alike. 427*9fd890c3Stracker-user * 428*9fd890c3Stracker-user * @param array $overrides applied last 429*9fd890c3Stracker-user * @return array 430*9fd890c3Stracker-user */ 431*9fd890c3Stracker-user protected function standingParams(array $overrides = []) 432*9fd890c3Stracker-user { 433*9fd890c3Stracker-user global $INPUT; 434*9fd890c3Stracker-user $params = ['do' => 'admin', 'page' => 'annotations']; 435*9fd890c3Stracker-user 436*9fd890c3Stracker-user $sort = $INPUT->str('sort'); 437*9fd890c3Stracker-user if (in_array($sort, $this->sortable, true)) { 438*9fd890c3Stracker-user $params['sort'] = $sort; 439*9fd890c3Stracker-user } 440*9fd890c3Stracker-user if ($INPUT->str('dir') === 'desc') { 441*9fd890c3Stracker-user $params['dir'] = 'desc'; 442*9fd890c3Stracker-user } 443*9fd890c3Stracker-user $filters = $this->activeFilters(); 444*9fd890c3Stracker-user if ($filters !== []) { 445*9fd890c3Stracker-user $params['q'] = $filters; 446*9fd890c3Stracker-user } 447*9fd890c3Stracker-user $pg = $INPUT->int('pg', 0); 448*9fd890c3Stracker-user if ($pg > 1) { 449*9fd890c3Stracker-user $params['pg'] = $pg; 450*9fd890c3Stracker-user } 451*9fd890c3Stracker-user return array_merge($params, $overrides); 452*9fd890c3Stracker-user } 453*9fd890c3Stracker-user 454*9fd890c3Stracker-user /** 455*9fd890c3Stracker-user * Build an in-table URL back to this admin page. 456*9fd890c3Stracker-user * 457*9fd890c3Stracker-user * @param array $params full query parameters (incl. do/page) 458*9fd890c3Stracker-user * @return string HTML-attribute-safe URL 459*9fd890c3Stracker-user */ 460*9fd890c3Stracker-user protected function tableURL(array $params) 461*9fd890c3Stracker-user { 462*9fd890c3Stracker-user global $ID; 463*9fd890c3Stracker-user return wl($ID, $params, false, '&'); 464*9fd890c3Stracker-user } 465*9fd890c3Stracker-user 466*9fd890c3Stracker-user /** 467*9fd890c3Stracker-user * Emit a column header. Sortable columns link to a re-sort (clicking the 468*9fd890c3Stracker-user * active column flips the direction and resets to page 1); the actions 469*9fd890c3Stracker-user * column is plain text. The active filter is preserved via standingParams. 470*9fd890c3Stracker-user * 471*9fd890c3Stracker-user * @param string $key column key 472*9fd890c3Stracker-user * @param string $label visible header text 473*9fd890c3Stracker-user * @param string $sort currently active sort column 474*9fd890c3Stracker-user * @param string $dir currently active direction 475*9fd890c3Stracker-user * @return string 476*9fd890c3Stracker-user */ 477*9fd890c3Stracker-user protected function headerCell($key, $label, $sort, $dir) 478*9fd890c3Stracker-user { 479*9fd890c3Stracker-user if (!in_array($key, $this->sortable, true)) { 480*9fd890c3Stracker-user return '<th>' . hsc($label) . '</th>'; 481*9fd890c3Stracker-user } 482*9fd890c3Stracker-user 483*9fd890c3Stracker-user $newDir = ($sort === $key && $dir === 'asc') ? 'desc' : 'asc'; 484*9fd890c3Stracker-user $arrow = ''; 485*9fd890c3Stracker-user if ($sort === $key) { 486*9fd890c3Stracker-user // ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd) 487*9fd890c3Stracker-user $arrow = ($dir === 'asc') ? ' ▲' : ' ▼'; 488*9fd890c3Stracker-user } 489*9fd890c3Stracker-user 490*9fd890c3Stracker-user // wl() already returns an HTML-safe URL (its & separator); it must 491*9fd890c3Stracker-user // NOT be passed through hsc() or the ampersands double-encode. The 492*9fd890c3Stracker-user // label is plain text and IS hsc()'d. 493*9fd890c3Stracker-user $url = $this->tableURL($this->standingParams(['sort' => $key, 'dir' => $newDir, 'pg' => 1])); 494*9fd890c3Stracker-user return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 495*9fd890c3Stracker-user } 496*9fd890c3Stracker-user 497*9fd890c3Stracker-user /** 498*9fd890c3Stracker-user * The per-column filter row: a text input under Page, empty cells under the 499*9fd890c3Stracker-user * count columns (numbers are not filtered), and the Search/Clear controls 500*9fd890c3Stracker-user * under Actions. 501*9fd890c3Stracker-user * 502*9fd890c3Stracker-user * @param string[] $cols visible columns in order 503*9fd890c3Stracker-user * @param array $filters active filters 504*9fd890c3Stracker-user * @param string $sort 505*9fd890c3Stracker-user * @param string $dir 506*9fd890c3Stracker-user * @return string 507*9fd890c3Stracker-user */ 508*9fd890c3Stracker-user protected function renderFilterRow(array $cols, array $filters, $sort, $dir) 509*9fd890c3Stracker-user { 510*9fd890c3Stracker-user $html = '<tr class="annotations_admin_filterrow">'; 511*9fd890c3Stracker-user foreach ($cols as $c) { 512*9fd890c3Stracker-user if ($c === 'page') { 513*9fd890c3Stracker-user $val = isset($filters['page']) ? hsc($filters['page']) : ''; 514*9fd890c3Stracker-user $html .= '<td><input type="text" name="q[page]" class="edit" value="' . $val . '" /></td>'; 515*9fd890c3Stracker-user } elseif ($c === 'actions') { 516*9fd890c3Stracker-user $html .= '<td class="annotations_admin_filteractions">'; 517*9fd890c3Stracker-user $html .= '<button type="submit" class="button">' 518*9fd890c3Stracker-user . hsc($this->getLang('filter_search')) . '</button>'; 519*9fd890c3Stracker-user if ($filters !== []) { 520*9fd890c3Stracker-user $clear = $this->tableURL([ 521*9fd890c3Stracker-user 'do' => 'admin', 522*9fd890c3Stracker-user 'page' => 'annotations', 523*9fd890c3Stracker-user 'sort' => $sort, 524*9fd890c3Stracker-user 'dir' => $dir, 525*9fd890c3Stracker-user ]); 526*9fd890c3Stracker-user $html .= ' <a class="annotations_admin_clear" href="' . $clear . '">' 527*9fd890c3Stracker-user . hsc($this->getLang('filter_clear')) . '</a>'; 528*9fd890c3Stracker-user } 529*9fd890c3Stracker-user $html .= '</td>'; 530*9fd890c3Stracker-user } else { 531*9fd890c3Stracker-user $html .= '<td></td>'; 532*9fd890c3Stracker-user } 533*9fd890c3Stracker-user } 534*9fd890c3Stracker-user return $html . '</tr>'; 535*9fd890c3Stracker-user } 536*9fd890c3Stracker-user 537*9fd890c3Stracker-user /** 538*9fd890c3Stracker-user * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Empty string 539*9fd890c3Stracker-user * when there is only one page. Links preserve sort + filter via standingParams. 540*9fd890c3Stracker-user * 541*9fd890c3Stracker-user * @param int $page 542*9fd890c3Stracker-user * @param int $totalPages 543*9fd890c3Stracker-user * @return string 544*9fd890c3Stracker-user */ 545*9fd890c3Stracker-user protected function renderPager($page, $totalPages) 546*9fd890c3Stracker-user { 547*9fd890c3Stracker-user if ($totalPages <= 1) { 548*9fd890c3Stracker-user return ''; 549*9fd890c3Stracker-user } 550*9fd890c3Stracker-user 551*9fd890c3Stracker-user $html = '<nav class="annotations_admin_pager" aria-label="' . hsc($this->getLang('pager_label')) . '">'; 552*9fd890c3Stracker-user 553*9fd890c3Stracker-user if ($page > 1) { 554*9fd890c3Stracker-user $html .= $this->pagerLink($page - 1, '‹', 'pager_prev'); 555*9fd890c3Stracker-user } else { 556*9fd890c3Stracker-user $html .= '<span class="pager_btn pager_disabled">‹</span>'; 557*9fd890c3Stracker-user } 558*9fd890c3Stracker-user 559*9fd890c3Stracker-user foreach ($this->pageWindow($page, $totalPages) as $p) { 560*9fd890c3Stracker-user if ($p === 0) { 561*9fd890c3Stracker-user $html .= '<span class="pager_gap">…</span>'; 562*9fd890c3Stracker-user } elseif ($p === $page) { 563*9fd890c3Stracker-user $html .= '<span class="pager_cur">' . $p . '</span>'; 564*9fd890c3Stracker-user } else { 565*9fd890c3Stracker-user $html .= $this->pagerLink($p, (string) $p, ''); 566*9fd890c3Stracker-user } 567*9fd890c3Stracker-user } 568*9fd890c3Stracker-user 569*9fd890c3Stracker-user if ($page < $totalPages) { 570*9fd890c3Stracker-user $html .= $this->pagerLink($page + 1, '›', 'pager_next'); 571*9fd890c3Stracker-user } else { 572*9fd890c3Stracker-user $html .= '<span class="pager_btn pager_disabled">›</span>'; 573*9fd890c3Stracker-user } 574*9fd890c3Stracker-user 575*9fd890c3Stracker-user return $html . '</nav>'; 576*9fd890c3Stracker-user } 577*9fd890c3Stracker-user 578*9fd890c3Stracker-user /** 579*9fd890c3Stracker-user * One pager link (number or arrow), preserving sort + filter. 580*9fd890c3Stracker-user * 581*9fd890c3Stracker-user * @param int $p target page 582*9fd890c3Stracker-user * @param string $text already-safe link text (number or entity) 583*9fd890c3Stracker-user * @param string $titleKey lang key for the title attribute, or '' for none 584*9fd890c3Stracker-user * @return string 585*9fd890c3Stracker-user */ 586*9fd890c3Stracker-user protected function pagerLink($p, $text, $titleKey) 587*9fd890c3Stracker-user { 588*9fd890c3Stracker-user $url = $this->tableURL($this->standingParams(['pg' => $p])); 589*9fd890c3Stracker-user $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; 590*9fd890c3Stracker-user return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>'; 591*9fd890c3Stracker-user } 592*9fd890c3Stracker-user 593*9fd890c3Stracker-user /** 594*9fd890c3Stracker-user * Page numbers to show around the current page, with 0 marking an elided 595*9fd890c3Stracker-user * gap. Always includes the first and last page. 596*9fd890c3Stracker-user * 597*9fd890c3Stracker-user * @param int $page 598*9fd890c3Stracker-user * @param int $totalPages 599*9fd890c3Stracker-user * @return int[] 600*9fd890c3Stracker-user */ 601*9fd890c3Stracker-user protected function pageWindow($page, $totalPages) 602*9fd890c3Stracker-user { 603*9fd890c3Stracker-user $window = 2; 604*9fd890c3Stracker-user $keep = []; 605*9fd890c3Stracker-user for ($i = 1; $i <= $totalPages; $i++) { 606*9fd890c3Stracker-user if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { 607*9fd890c3Stracker-user $keep[] = $i; 608*9fd890c3Stracker-user } 609*9fd890c3Stracker-user } 610*9fd890c3Stracker-user 611*9fd890c3Stracker-user $out = []; 612*9fd890c3Stracker-user $prev = 0; 613*9fd890c3Stracker-user foreach ($keep as $p) { 614*9fd890c3Stracker-user if ($prev && ($p - $prev) > 1) { 615*9fd890c3Stracker-user $out[] = 0; // gap marker 616*9fd890c3Stracker-user } 617*9fd890c3Stracker-user $out[] = $p; 618*9fd890c3Stracker-user $prev = $p; 619*9fd890c3Stracker-user } 620*9fd890c3Stracker-user return $out; 621*9fd890c3Stracker-user } 622*9fd890c3Stracker-user 623*9fd890c3Stracker-user // --------------------------------------------------------------------- 624*9fd890c3Stracker-user // Clear-action POST forms 625*9fd890c3Stracker-user // --------------------------------------------------------------------- 626*9fd890c3Stracker-user 627*9fd890c3Stracker-user /** 628*9fd890c3Stracker-user * The two POST forms the clear buttons submit (via the HTML5 form= attr): 629*9fd890c3Stracker-user * one for a single page (the page id arrives from the button's value) and 630*9fd890c3Stracker-user * one for the wiki-wide sweep. Both carry the CSRF token and the standing 631*9fd890c3Stracker-user * sort/filter/page so the Post/Redirect/Get lands back on the same view. 632*9fd890c3Stracker-user * 633*9fd890c3Stracker-user * @return string 634*9fd890c3Stracker-user */ 635*9fd890c3Stracker-user protected function clearForms() 636*9fd890c3Stracker-user { 637*9fd890c3Stracker-user global $ID; 638*9fd890c3Stracker-user $action = DOKU_BASE . DOKU_SCRIPT; 639*9fd890c3Stracker-user $std = $this->standingHiddenFields(); 640*9fd890c3Stracker-user 641*9fd890c3Stracker-user $base = '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 642*9fd890c3Stracker-user $base .= '<input type="hidden" name="do" value="admin" />'; 643*9fd890c3Stracker-user $base .= '<input type="hidden" name="page" value="annotations" />'; 644*9fd890c3Stracker-user 645*9fd890c3Stracker-user $single = '<form id="ann_clear_single" method="post" action="' . $action . '" class="annotations_admin_post">'; 646*9fd890c3Stracker-user $single .= formSecurityToken(false); 647*9fd890c3Stracker-user $single .= $base; 648*9fd890c3Stracker-user $single .= '<input type="hidden" name="annotations_action" value="clear_orphaned" />'; 649*9fd890c3Stracker-user $single .= $std; 650*9fd890c3Stracker-user $single .= '</form>'; 651*9fd890c3Stracker-user 652*9fd890c3Stracker-user $all = '<form id="ann_clear_all" method="post" action="' . $action . '" class="annotations_admin_post">'; 653*9fd890c3Stracker-user $all .= formSecurityToken(false); 654*9fd890c3Stracker-user $all .= $base; 655*9fd890c3Stracker-user $all .= '<input type="hidden" name="annotations_action" value="clear_orphaned_all" />'; 656*9fd890c3Stracker-user $all .= $std; 657*9fd890c3Stracker-user $all .= '</form>'; 658*9fd890c3Stracker-user 659*9fd890c3Stracker-user return $single . $all; 660*9fd890c3Stracker-user } 661*9fd890c3Stracker-user 662*9fd890c3Stracker-user /** 663*9fd890c3Stracker-user * The standing sort/filter/page state as hidden inputs, so a clear action's 664*9fd890c3Stracker-user * redirect (which rebuilds the URL from $INPUT) preserves the current view. 665*9fd890c3Stracker-user * 666*9fd890c3Stracker-user * @return string 667*9fd890c3Stracker-user */ 668*9fd890c3Stracker-user protected function standingHiddenFields() 669*9fd890c3Stracker-user { 670*9fd890c3Stracker-user global $INPUT; 671*9fd890c3Stracker-user $html = ''; 672*9fd890c3Stracker-user 673*9fd890c3Stracker-user $sort = $INPUT->str('sort'); 674*9fd890c3Stracker-user if (in_array($sort, $this->sortable, true)) { 675*9fd890c3Stracker-user $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 676*9fd890c3Stracker-user } 677*9fd890c3Stracker-user if ($INPUT->str('dir') === 'desc') { 678*9fd890c3Stracker-user $html .= '<input type="hidden" name="dir" value="desc" />'; 679*9fd890c3Stracker-user } 680*9fd890c3Stracker-user $filters = $this->activeFilters(); 681*9fd890c3Stracker-user if (isset($filters['page'])) { 682*9fd890c3Stracker-user $html .= '<input type="hidden" name="q[page]" value="' . hsc($filters['page']) . '" />'; 683*9fd890c3Stracker-user } 684*9fd890c3Stracker-user $pg = $INPUT->int('pg', 0); 685*9fd890c3Stracker-user if ($pg > 1) { 686*9fd890c3Stracker-user $html .= '<input type="hidden" name="pg" value="' . $pg . '" />'; 687*9fd890c3Stracker-user } 688*9fd890c3Stracker-user return $html; 689*9fd890c3Stracker-user } 690*9fd890c3Stracker-user 691*9fd890c3Stracker-user /** 692*9fd890c3Stracker-user * An onclick attribute that confirms before submitting, escaped safely for 693*9fd890c3Stracker-user * both the HTML-attribute and the JS-string layers (json_encode escapes the 694*9fd890c3Stracker-user * quotes inside the message, hsc escapes the attribute). 695*9fd890c3Stracker-user * 696*9fd890c3Stracker-user * @param string $key lang key of the confirmation message 697*9fd890c3Stracker-user * @return string e.g. ' onclick="return confirm("…")"' 698*9fd890c3Stracker-user */ 699*9fd890c3Stracker-user protected function confirmAttr($key) 700*9fd890c3Stracker-user { 701*9fd890c3Stracker-user $js = 'return confirm(' 702*9fd890c3Stracker-user . json_encode($this->getLang($key), JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) 703*9fd890c3Stracker-user . ');'; 704*9fd890c3Stracker-user return ' onclick="' . hsc($js) . '"'; 705*9fd890c3Stracker-user } 706*9fd890c3Stracker-user} 707