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