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