19fd890c3Stracker-user<?php 29fd890c3Stracker-user 39fd890c3Stracker-user/** 49fd890c3Stracker-user * Annotations plugin — admin overview. 59fd890c3Stracker-user * 6*72d60f2dStracker-user * Lists every page that has stored annotations, with its normal (present), 7*72d60f2dStracker-user * resolved and orphaned counts, and lets an admin clear resolved or orphaned 8*72d60f2dStracker-user * annotations for one page or for every annotated page at once. 99fd890c3Stracker-user * 109fd890c3Stracker-user * The table reuses the JS-free pattern shared by the lastseen and usersettings 119fd890c3Stracker-user * admin panels: sortable column headers, a per-column text filter (only the 129fd890c3Stracker-user * Page column is filterable here — the count columns and the actions column are 139fd890c3Stracker-user * not), and a numbered pager. The clear operations are POST forms guarded by a 149fd890c3Stracker-user * CSRF token, with Post/Redirect/Get back to the overview. 159fd890c3Stracker-user * 169fd890c3Stracker-user * Permission: admin only (forAdminOnly), matching the helper's canClear() rule. 179fd890c3Stracker-user * The counts are computed by helper::pageCounts(), which renders each page to 189fd890c3Stracker-user * detect orphans (cached by p_wiki_xhtml); see DESIGN.md for the cost note. 199fd890c3Stracker-user */ 209fd890c3Stracker-user 219fd890c3Stracker-user// must be run within DokuWiki 229fd890c3Stracker-userif (!defined('DOKU_INC')) die(); 239fd890c3Stracker-user 249fd890c3Stracker-useruse dokuwiki\Extension\AdminPlugin; 259fd890c3Stracker-useruse dokuwiki\Utf8\PhpString; 269fd890c3Stracker-user 279fd890c3Stracker-userclass admin_plugin_annotations extends AdminPlugin 289fd890c3Stracker-user{ 299fd890c3Stracker-user /** @var string[] columns that may be sorted */ 30*72d60f2dStracker-user protected $sortable = ['page', 'normal', 'resolved', 'orphaned']; 319fd890c3Stracker-user 329fd890c3Stracker-user /** 339fd890c3Stracker-user * Admin only — clearing annotations is an admin operation (helper::canClear). 349fd890c3Stracker-user * 359fd890c3Stracker-user * @return bool 369fd890c3Stracker-user */ 379fd890c3Stracker-user public function forAdminOnly() 389fd890c3Stracker-user { 399fd890c3Stracker-user return true; 409fd890c3Stracker-user } 419fd890c3Stracker-user 429fd890c3Stracker-user /** 439fd890c3Stracker-user * Position in the admin menu. 449fd890c3Stracker-user * 459fd890c3Stracker-user * @return int 469fd890c3Stracker-user */ 479fd890c3Stracker-user public function getMenuSort() 489fd890c3Stracker-user { 499fd890c3Stracker-user return 1000; 509fd890c3Stracker-user } 519fd890c3Stracker-user 529fd890c3Stracker-user /** 539fd890c3Stracker-user * Admin-menu label. 549fd890c3Stracker-user * 559fd890c3Stracker-user * @param string $language 569fd890c3Stracker-user * @return string 579fd890c3Stracker-user */ 589fd890c3Stracker-user public function getMenuText($language) 599fd890c3Stracker-user { 609fd890c3Stracker-user return $this->getLang('menu'); 619fd890c3Stracker-user } 629fd890c3Stracker-user 639fd890c3Stracker-user // --------------------------------------------------------------------- 649fd890c3Stracker-user // Request handling (clear actions) 659fd890c3Stracker-user // --------------------------------------------------------------------- 669fd890c3Stracker-user 679fd890c3Stracker-user /** 689fd890c3Stracker-user * Process a submitted clear action, then Post/Redirect/Get back to the 699fd890c3Stracker-user * overview so a reload does not repeat it. DokuWiki's admin dispatcher 709fd890c3Stracker-user * enforces forAdminOnly() before this runs; the CSRF token and the helper's 719fd890c3Stracker-user * canClear() rule are still checked. 729fd890c3Stracker-user * 739fd890c3Stracker-user * @return void 749fd890c3Stracker-user */ 759fd890c3Stracker-user public function handle() 769fd890c3Stracker-user { 779fd890c3Stracker-user global $INPUT, $ID; 789fd890c3Stracker-user 799fd890c3Stracker-user $action = $INPUT->post->str('annotations_action'); 809fd890c3Stracker-user if ($action === '') { 819fd890c3Stracker-user return; 829fd890c3Stracker-user } 839fd890c3Stracker-user if (!checkSecurityToken()) { 849fd890c3Stracker-user return; 859fd890c3Stracker-user } 869fd890c3Stracker-user 879fd890c3Stracker-user /** @var helper_plugin_annotations $helper */ 889fd890c3Stracker-user $helper = $this->loadHelper('annotations', false); 899fd890c3Stracker-user if (!$helper || !$helper->canClear(auth_isadmin())) { 909fd890c3Stracker-user return; 919fd890c3Stracker-user } 929fd890c3Stracker-user 939fd890c3Stracker-user if ($action === 'clear_orphaned') { 949fd890c3Stracker-user $page = cleanID($INPUT->post->str('clearpage')); 959fd890c3Stracker-user if ($page !== '') { 969fd890c3Stracker-user $count = $helper->clearOrphaned($page); 979fd890c3Stracker-user if ($count === false) { 989fd890c3Stracker-user msg($this->getLang('clear_fail'), -1); 999fd890c3Stracker-user } else { 1009fd890c3Stracker-user msg(sprintf($this->getLang('cleared_page'), $count, hsc($page)), 1); 1019fd890c3Stracker-user } 1029fd890c3Stracker-user } 1039fd890c3Stracker-user } elseif ($action === 'clear_orphaned_all') { 1049fd890c3Stracker-user $count = $helper->clearOrphanedAll(); 1059fd890c3Stracker-user msg(sprintf($this->getLang('cleared_all'), $count), 1); 106*72d60f2dStracker-user } elseif ($action === 'clear_resolved') { 107*72d60f2dStracker-user $page = cleanID($INPUT->post->str('clearpage')); 108*72d60f2dStracker-user if ($page !== '') { 109*72d60f2dStracker-user $count = $helper->clearResolved($page); 110*72d60f2dStracker-user if ($count === false) { 111*72d60f2dStracker-user msg($this->getLang('clear_resolved_fail'), -1); 112*72d60f2dStracker-user } else { 113*72d60f2dStracker-user msg(sprintf($this->getLang('cleared_resolved_page'), $count, hsc($page)), 1); 114*72d60f2dStracker-user } 115*72d60f2dStracker-user } 116*72d60f2dStracker-user } elseif ($action === 'clear_resolved_all') { 117*72d60f2dStracker-user $count = $helper->clearResolvedAll(); 118*72d60f2dStracker-user msg(sprintf($this->getLang('cleared_resolved_all'), $count), 1); 1199fd890c3Stracker-user } 1209fd890c3Stracker-user 1219fd890c3Stracker-user send_redirect(wl($ID, $this->standingParams(), true, '&')); 1229fd890c3Stracker-user } 1239fd890c3Stracker-user 1249fd890c3Stracker-user // --------------------------------------------------------------------- 1259fd890c3Stracker-user // Output 1269fd890c3Stracker-user // --------------------------------------------------------------------- 1279fd890c3Stracker-user 1289fd890c3Stracker-user /** 1299fd890c3Stracker-user * Render the overview: a wiki-wide "clear all orphaned" button, then a 1309fd890c3Stracker-user * sortable/filterable/paginated table of annotated pages. 1319fd890c3Stracker-user * 1329fd890c3Stracker-user * @return void 1339fd890c3Stracker-user */ 1349fd890c3Stracker-user public function html() 1359fd890c3Stracker-user { 1369fd890c3Stracker-user global $INPUT, $ID; 1379fd890c3Stracker-user 1389fd890c3Stracker-user echo '<div class="plugin_annotations_admin">'; 1399fd890c3Stracker-user echo '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 1409fd890c3Stracker-user 1419fd890c3Stracker-user /** @var helper_plugin_annotations $helper */ 1429fd890c3Stracker-user $helper = $this->loadHelper('annotations', false); 1439fd890c3Stracker-user if (!$helper) { 1449fd890c3Stracker-user echo '<div class="error">' . hsc($this->getLang('helper_missing')) . '</div></div>'; 1459fd890c3Stracker-user return; 1469fd890c3Stracker-user } 1479fd890c3Stracker-user 1489fd890c3Stracker-user // Build every row up front: the counts feed sorting, filtering and the 1499fd890c3Stracker-user // wiki-wide orphan total, so all annotated pages are processed here. 1509fd890c3Stracker-user $rows = $this->buildRows($helper); 1519fd890c3Stracker-user if ($rows === []) { 1529fd890c3Stracker-user echo '<p class="annotations_admin_none">' . hsc($this->getLang('none')) . '</p>'; 1539fd890c3Stracker-user echo '</div>'; 1549fd890c3Stracker-user return; 1559fd890c3Stracker-user } 1569fd890c3Stracker-user 1579fd890c3Stracker-user $totalOrphaned = 0; 158*72d60f2dStracker-user $totalResolved = 0; 1599fd890c3Stracker-user foreach ($rows as $r) { 1609fd890c3Stracker-user $totalOrphaned += $r['orphaned']; 161*72d60f2dStracker-user $totalResolved += $r['resolved']; 1629fd890c3Stracker-user } 1639fd890c3Stracker-user 1649fd890c3Stracker-user // request parameters 1659fd890c3Stracker-user $sort = $INPUT->str('sort', 'page'); 1669fd890c3Stracker-user if (!in_array($sort, $this->sortable, true)) { 1679fd890c3Stracker-user $sort = 'page'; 1689fd890c3Stracker-user } 1699fd890c3Stracker-user $dir = ($INPUT->str('dir') === 'desc') ? 'desc' : 'asc'; 1709fd890c3Stracker-user $filters = $this->activeFilters(); 1719fd890c3Stracker-user 1729fd890c3Stracker-user $shown = $this->applyFilters($rows, $filters); 1739fd890c3Stracker-user $shown = $this->sortRows($shown, $sort, $dir); 1749fd890c3Stracker-user $total = count($shown); 1759fd890c3Stracker-user 1769fd890c3Stracker-user [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($shown, (int) $this->getConf('entries_per_page')); 1779fd890c3Stracker-user 1789fd890c3Stracker-user echo '<p>' . hsc($this->getLang('intro')) . '</p>'; 1799fd890c3Stracker-user 1809fd890c3Stracker-user // wiki-wide clear-all (counts all annotated pages, not just the filtered 181*72d60f2dStracker-user // view); each button targets a POST form rendered at the end via form= 1829fd890c3Stracker-user echo '<div class="annotations_admin_bar">'; 183*72d60f2dStracker-user if ($totalResolved > 0) { 184*72d60f2dStracker-user echo '<button type="submit" form="ann_clear_resolved_all" class="button"' 185*72d60f2dStracker-user . $this->confirmAttr('confirm_clear_resolved_all') . '>' 186*72d60f2dStracker-user . hsc(sprintf($this->getLang('btn_clear_resolved_all'), $totalResolved)) . '</button>'; 187*72d60f2dStracker-user } 1889fd890c3Stracker-user if ($totalOrphaned > 0) { 189*72d60f2dStracker-user echo '<button type="submit" form="ann_clear_orphaned_all" class="button"' 1909fd890c3Stracker-user . $this->confirmAttr('confirm_clear_all') . '>' 1919fd890c3Stracker-user . hsc(sprintf($this->getLang('btn_clear_all'), $totalOrphaned)) . '</button>'; 1929fd890c3Stracker-user } 1939fd890c3Stracker-user echo '</div>'; 1949fd890c3Stracker-user 195*72d60f2dStracker-user $cols = ['page', 'normal', 'resolved', 'orphaned', 'actions']; 1969fd890c3Stracker-user $labels = [ 1979fd890c3Stracker-user 'page' => $this->getLang('th_page'), 1989fd890c3Stracker-user 'normal' => $this->getLang('th_normal'), 199*72d60f2dStracker-user 'resolved' => $this->getLang('th_resolved'), 2009fd890c3Stracker-user 'orphaned' => $this->getLang('th_orphaned'), 2019fd890c3Stracker-user 'actions' => $this->getLang('th_actions'), 2029fd890c3Stracker-user ]; 2039fd890c3Stracker-user 2049fd890c3Stracker-user // GET form so the filter combines with the sort links and bookmarks 2059fd890c3Stracker-user // cleanly; the action URL's query string is dropped on submit, so the 2069fd890c3Stracker-user // standing parameters travel as hidden fields. 2079fd890c3Stracker-user echo '<form class="annotations_admin_filter" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 2089fd890c3Stracker-user echo '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 2099fd890c3Stracker-user echo '<input type="hidden" name="do" value="admin" />'; 2109fd890c3Stracker-user echo '<input type="hidden" name="page" value="annotations" />'; 2119fd890c3Stracker-user echo '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 2129fd890c3Stracker-user echo '<input type="hidden" name="dir" value="' . hsc($dir) . '" />'; 2139fd890c3Stracker-user echo '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1 2149fd890c3Stracker-user 2159fd890c3Stracker-user echo '<div class="table">'; 2169fd890c3Stracker-user echo '<table class="inline plugin_annotations_admin">'; 2179fd890c3Stracker-user echo '<thead><tr>'; 2189fd890c3Stracker-user foreach ($cols as $c) { 2199fd890c3Stracker-user echo $this->headerCell($c, $labels[$c], $sort, $dir); 2209fd890c3Stracker-user } 2219fd890c3Stracker-user echo '</tr>'; 2229fd890c3Stracker-user echo $this->renderFilterRow($cols, $filters, $sort, $dir); 2239fd890c3Stracker-user echo '</thead><tbody>'; 2249fd890c3Stracker-user 2259fd890c3Stracker-user if ($total === 0) { 2269fd890c3Stracker-user echo '<tr><td colspan="' . count($cols) . '" class="annotations_admin_none">' 2279fd890c3Stracker-user . hsc($this->getLang('none_match')) . '</td></tr>'; 2289fd890c3Stracker-user } else { 2299fd890c3Stracker-user foreach ($pageRows as $row) { 2309fd890c3Stracker-user echo $this->renderRow($row); 2319fd890c3Stracker-user } 2329fd890c3Stracker-user } 2339fd890c3Stracker-user 2349fd890c3Stracker-user echo '</tbody></table></div>'; 2359fd890c3Stracker-user echo '</form>'; 2369fd890c3Stracker-user 2379fd890c3Stracker-user echo $this->renderPager($page, $totalPages); 2389fd890c3Stracker-user 2399fd890c3Stracker-user if ($total > 0) { 2409fd890c3Stracker-user echo '<p class="annotations_admin_count">' 2419fd890c3Stracker-user . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>'; 2429fd890c3Stracker-user } 2439fd890c3Stracker-user 2449fd890c3Stracker-user // POST forms targeted by the clear buttons (siblings of the GET form, so 2459fd890c3Stracker-user // no illegal nested <form>; buttons reach them via the HTML5 form= attr) 2469fd890c3Stracker-user echo $this->clearForms(); 2479fd890c3Stracker-user 2489fd890c3Stracker-user echo '</div>'; 2499fd890c3Stracker-user } 2509fd890c3Stracker-user 2519fd890c3Stracker-user /** 252*72d60f2dStracker-user * One table row per annotated page: counts plus per-page "clear resolved" 253*72d60f2dStracker-user * and "clear orphaned" submit buttons, each shown only when that page has 254*72d60f2dStracker-user * something of its kind to clear. 2559fd890c3Stracker-user * 256*72d60f2dStracker-user * @param array $row ['id','title','normal','resolved','orphaned'] 2579fd890c3Stracker-user * @return string 2589fd890c3Stracker-user */ 2599fd890c3Stracker-user protected function renderRow(array $row) 2609fd890c3Stracker-user { 2619fd890c3Stracker-user $html = '<tr>'; 2629fd890c3Stracker-user 2639fd890c3Stracker-user // Page: title as a link to the page; the id as muted secondary text 2649fd890c3Stracker-user // (omitted when the title is just the id, i.e. the page has no heading). 2659fd890c3Stracker-user $html .= '<td class="annotations_admin_page">'; 2669fd890c3Stracker-user $html .= '<a href="' . wl($row['id']) . '">' . hsc($row['title']) . '</a>'; 2679fd890c3Stracker-user if ($row['title'] !== $row['id']) { 2689fd890c3Stracker-user $html .= ' <span class="annotations_admin_id">' . hsc($row['id']) . '</span>'; 2699fd890c3Stracker-user } 2709fd890c3Stracker-user $html .= '</td>'; 2719fd890c3Stracker-user 2729fd890c3Stracker-user $html .= '<td class="annotations_admin_num">' . ((int) $row['normal']) . '</td>'; 273*72d60f2dStracker-user $html .= '<td class="annotations_admin_num">' . ((int) $row['resolved']) . '</td>'; 2749fd890c3Stracker-user $html .= '<td class="annotations_admin_num">' . ((int) $row['orphaned']) . '</td>'; 2759fd890c3Stracker-user 2769fd890c3Stracker-user $html .= '<td class="annotations_admin_actions">'; 277*72d60f2dStracker-user if ($row['resolved'] > 0) { 278*72d60f2dStracker-user $html .= '<button type="submit" form="ann_clear_resolved_single" class="button"' 279*72d60f2dStracker-user . ' name="clearpage" value="' . hsc($row['id']) . '"' 280*72d60f2dStracker-user . $this->confirmAttr('confirm_clear_resolved_page') . '>' 281*72d60f2dStracker-user . hsc($this->getLang('btn_clear_resolved')) . '</button>'; 282*72d60f2dStracker-user } 2839fd890c3Stracker-user if ($row['orphaned'] > 0) { 284*72d60f2dStracker-user if ($row['resolved'] > 0) { 285*72d60f2dStracker-user $html .= ' '; 286*72d60f2dStracker-user } 287*72d60f2dStracker-user $html .= '<button type="submit" form="ann_clear_orphaned_single" class="button"' 2889fd890c3Stracker-user . ' name="clearpage" value="' . hsc($row['id']) . '"' 2899fd890c3Stracker-user . $this->confirmAttr('confirm_clear_page') . '>' 2909fd890c3Stracker-user . hsc($this->getLang('btn_clear_orphaned')) . '</button>'; 2919fd890c3Stracker-user } 2929fd890c3Stracker-user $html .= '</td>'; 2939fd890c3Stracker-user 2949fd890c3Stracker-user return $html . '</tr>'; 2959fd890c3Stracker-user } 2969fd890c3Stracker-user 2979fd890c3Stracker-user /** 2989fd890c3Stracker-user * Build one row per annotated page. Pages whose annotation file is present 2999fd890c3Stracker-user * but empty are skipped (helper::getAnnotatedPages already filters those, 3009fd890c3Stracker-user * but the count is re-checked here too). 3019fd890c3Stracker-user * 3029fd890c3Stracker-user * @param helper_plugin_annotations $helper 303*72d60f2dStracker-user * @return array list of ['id','title','normal','resolved','orphaned'] 3049fd890c3Stracker-user */ 3059fd890c3Stracker-user protected function buildRows($helper) 3069fd890c3Stracker-user { 3079fd890c3Stracker-user $rows = []; 3089fd890c3Stracker-user foreach ($helper->getAnnotatedPages() as $id) { 3099fd890c3Stracker-user $counts = $helper->pageCounts($id); 3109fd890c3Stracker-user if ($counts['total'] === 0) { 3119fd890c3Stracker-user continue; 3129fd890c3Stracker-user } 3139fd890c3Stracker-user $title = p_get_first_heading($id); 3149fd890c3Stracker-user if (!is_string($title) || $title === '') { 3159fd890c3Stracker-user $title = $id; 3169fd890c3Stracker-user } 3179fd890c3Stracker-user $rows[] = [ 3189fd890c3Stracker-user 'id' => $id, 3199fd890c3Stracker-user 'title' => $title, 3209fd890c3Stracker-user 'normal' => $counts['normal'], 321*72d60f2dStracker-user 'resolved' => $counts['resolved'], 3229fd890c3Stracker-user 'orphaned' => $counts['orphaned'], 3239fd890c3Stracker-user ]; 3249fd890c3Stracker-user } 3259fd890c3Stracker-user return $rows; 3269fd890c3Stracker-user } 3279fd890c3Stracker-user 3289fd890c3Stracker-user // --------------------------------------------------------------------- 3299fd890c3Stracker-user // Filtering & sorting 3309fd890c3Stracker-user // --------------------------------------------------------------------- 3319fd890c3Stracker-user 3329fd890c3Stracker-user /** 3339fd890c3Stracker-user * The active page filter, read from the q[] array (only the Page column is 3349fd890c3Stracker-user * filterable). Returns [] or ['page' => term]. 3359fd890c3Stracker-user * 3369fd890c3Stracker-user * @return array 3379fd890c3Stracker-user */ 3389fd890c3Stracker-user protected function activeFilters() 3399fd890c3Stracker-user { 3409fd890c3Stracker-user global $INPUT; 3419fd890c3Stracker-user $raw = $INPUT->arr('q'); 3429fd890c3Stracker-user if (isset($raw['page']) && is_string($raw['page'])) { 3439fd890c3Stracker-user $term = trim($raw['page']); 3449fd890c3Stracker-user if ($term !== '') { 3459fd890c3Stracker-user return ['page' => $term]; 3469fd890c3Stracker-user } 3479fd890c3Stracker-user } 3489fd890c3Stracker-user return []; 3499fd890c3Stracker-user } 3509fd890c3Stracker-user 3519fd890c3Stracker-user /** 3529fd890c3Stracker-user * Keep only rows whose title OR id matches the page filter (substring, 3539fd890c3Stracker-user * case-insensitive). 3549fd890c3Stracker-user * 3559fd890c3Stracker-user * @param array $rows 3569fd890c3Stracker-user * @param array $filters 3579fd890c3Stracker-user * @return array 3589fd890c3Stracker-user */ 3599fd890c3Stracker-user protected function applyFilters(array $rows, array $filters) 3609fd890c3Stracker-user { 3619fd890c3Stracker-user if (!isset($filters['page'])) { 3629fd890c3Stracker-user return $rows; 3639fd890c3Stracker-user } 3649fd890c3Stracker-user $term = $filters['page']; 3659fd890c3Stracker-user return array_values(array_filter($rows, function ($row) use ($term) { 3669fd890c3Stracker-user return $this->matches($row['title'], $term) || $this->matches($row['id'], $term); 3679fd890c3Stracker-user })); 3689fd890c3Stracker-user } 3699fd890c3Stracker-user 3709fd890c3Stracker-user /** 3719fd890c3Stracker-user * Case-insensitive UTF-8 substring test. 3729fd890c3Stracker-user * 3739fd890c3Stracker-user * @param string $haystack 3749fd890c3Stracker-user * @param string $needle 3759fd890c3Stracker-user * @return bool 3769fd890c3Stracker-user */ 3779fd890c3Stracker-user protected function matches($haystack, $needle) 3789fd890c3Stracker-user { 3799fd890c3Stracker-user if ($needle === '') { 3809fd890c3Stracker-user return true; 3819fd890c3Stracker-user } 3829fd890c3Stracker-user $h = PhpString::strtolower((string) $haystack); 3839fd890c3Stracker-user $n = PhpString::strtolower((string) $needle); 3849fd890c3Stracker-user return PhpString::strpos($h, $n) !== false; 3859fd890c3Stracker-user } 3869fd890c3Stracker-user 3879fd890c3Stracker-user /** 3889fd890c3Stracker-user * Sort rows by the given column and direction. The page column sorts by 3899fd890c3Stracker-user * title (case-insensitive); the count columns sort numerically. The page id 3909fd890c3Stracker-user * is a stable tiebreak in every case. 3919fd890c3Stracker-user * 3929fd890c3Stracker-user * @param array $rows 3939fd890c3Stracker-user * @param string $sort one of $this->sortable 3949fd890c3Stracker-user * @param string $dir 'asc' or 'desc' 3959fd890c3Stracker-user * @return array 3969fd890c3Stracker-user */ 3979fd890c3Stracker-user protected function sortRows(array $rows, $sort, $dir) 3989fd890c3Stracker-user { 3999fd890c3Stracker-user usort($rows, static function ($a, $b) use ($sort) { 400*72d60f2dStracker-user if ($sort === 'normal' || $sort === 'resolved' || $sort === 'orphaned') { 4019fd890c3Stracker-user $cmp = $a[$sort] <=> $b[$sort]; 4029fd890c3Stracker-user } else { 4039fd890c3Stracker-user $cmp = strcasecmp((string) $a['title'], (string) $b['title']); 4049fd890c3Stracker-user } 4059fd890c3Stracker-user if ($cmp === 0) { 4069fd890c3Stracker-user $cmp = strcasecmp((string) $a['id'], (string) $b['id']); 4079fd890c3Stracker-user } 4089fd890c3Stracker-user return $cmp; 4099fd890c3Stracker-user }); 4109fd890c3Stracker-user if ($dir === 'desc') { 4119fd890c3Stracker-user $rows = array_reverse($rows); 4129fd890c3Stracker-user } 4139fd890c3Stracker-user return $rows; 4149fd890c3Stracker-user } 4159fd890c3Stracker-user 4169fd890c3Stracker-user /** 4179fd890c3Stracker-user * Slice the rows for the current page. 4189fd890c3Stracker-user * 4199fd890c3Stracker-user * @param array $rows filtered + sorted rows 4209fd890c3Stracker-user * @param int $perPage rows per page; <= 0 means "all on one page" 4219fd890c3Stracker-user * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based 4229fd890c3Stracker-user * row numbers of the slice (0 when there are no rows) 4239fd890c3Stracker-user */ 4249fd890c3Stracker-user protected function paginate(array $rows, $perPage) 4259fd890c3Stracker-user { 4269fd890c3Stracker-user global $INPUT; 4279fd890c3Stracker-user $total = count($rows); 4289fd890c3Stracker-user 4299fd890c3Stracker-user if ($perPage <= 0) { 4309fd890c3Stracker-user return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; 4319fd890c3Stracker-user } 4329fd890c3Stracker-user 4339fd890c3Stracker-user $totalPages = max(1, (int) ceil($total / $perPage)); 4349fd890c3Stracker-user $page = $INPUT->int('pg', 1); 4359fd890c3Stracker-user if ($page < 1) { 4369fd890c3Stracker-user $page = 1; 4379fd890c3Stracker-user } 4389fd890c3Stracker-user if ($page > $totalPages) { 4399fd890c3Stracker-user $page = $totalPages; 4409fd890c3Stracker-user } 4419fd890c3Stracker-user 4429fd890c3Stracker-user $offset = ($page - 1) * $perPage; 4439fd890c3Stracker-user $slice = array_slice($rows, $offset, $perPage); 4449fd890c3Stracker-user $from = $total > 0 ? $offset + 1 : 0; 4459fd890c3Stracker-user $to = min($total, $offset + $perPage); 4469fd890c3Stracker-user 4479fd890c3Stracker-user return [$slice, $page, $totalPages, $from, $to]; 4489fd890c3Stracker-user } 4499fd890c3Stracker-user 4509fd890c3Stracker-user // --------------------------------------------------------------------- 4519fd890c3Stracker-user // Link / header / filter-row / pager helpers 4529fd890c3Stracker-user // --------------------------------------------------------------------- 4539fd890c3Stracker-user 4549fd890c3Stracker-user /** 4559fd890c3Stracker-user * The standing query parameters every in-table link and the clear-action 4569fd890c3Stracker-user * redirect must carry: the admin page id, the active sort + direction, the 4579fd890c3Stracker-user * page filter and the current page number. Read from $INPUT (which merges 4589fd890c3Stracker-user * GET and POST) so it works for the GET table links and the POST clear 4599fd890c3Stracker-user * forms alike. 4609fd890c3Stracker-user * 4619fd890c3Stracker-user * @param array $overrides applied last 4629fd890c3Stracker-user * @return array 4639fd890c3Stracker-user */ 4649fd890c3Stracker-user protected function standingParams(array $overrides = []) 4659fd890c3Stracker-user { 4669fd890c3Stracker-user global $INPUT; 4679fd890c3Stracker-user $params = ['do' => 'admin', 'page' => 'annotations']; 4689fd890c3Stracker-user 4699fd890c3Stracker-user $sort = $INPUT->str('sort'); 4709fd890c3Stracker-user if (in_array($sort, $this->sortable, true)) { 4719fd890c3Stracker-user $params['sort'] = $sort; 4729fd890c3Stracker-user } 4739fd890c3Stracker-user if ($INPUT->str('dir') === 'desc') { 4749fd890c3Stracker-user $params['dir'] = 'desc'; 4759fd890c3Stracker-user } 4769fd890c3Stracker-user $filters = $this->activeFilters(); 4779fd890c3Stracker-user if ($filters !== []) { 4789fd890c3Stracker-user $params['q'] = $filters; 4799fd890c3Stracker-user } 4809fd890c3Stracker-user $pg = $INPUT->int('pg', 0); 4819fd890c3Stracker-user if ($pg > 1) { 4829fd890c3Stracker-user $params['pg'] = $pg; 4839fd890c3Stracker-user } 4849fd890c3Stracker-user return array_merge($params, $overrides); 4859fd890c3Stracker-user } 4869fd890c3Stracker-user 4879fd890c3Stracker-user /** 4889fd890c3Stracker-user * Build an in-table URL back to this admin page. 4899fd890c3Stracker-user * 4909fd890c3Stracker-user * @param array $params full query parameters (incl. do/page) 4919fd890c3Stracker-user * @return string HTML-attribute-safe URL 4929fd890c3Stracker-user */ 4939fd890c3Stracker-user protected function tableURL(array $params) 4949fd890c3Stracker-user { 4959fd890c3Stracker-user global $ID; 4969fd890c3Stracker-user return wl($ID, $params, false, '&'); 4979fd890c3Stracker-user } 4989fd890c3Stracker-user 4999fd890c3Stracker-user /** 5009fd890c3Stracker-user * Emit a column header. Sortable columns link to a re-sort (clicking the 5019fd890c3Stracker-user * active column flips the direction and resets to page 1); the actions 5029fd890c3Stracker-user * column is plain text. The active filter is preserved via standingParams. 5039fd890c3Stracker-user * 5049fd890c3Stracker-user * @param string $key column key 5059fd890c3Stracker-user * @param string $label visible header text 5069fd890c3Stracker-user * @param string $sort currently active sort column 5079fd890c3Stracker-user * @param string $dir currently active direction 5089fd890c3Stracker-user * @return string 5099fd890c3Stracker-user */ 5109fd890c3Stracker-user protected function headerCell($key, $label, $sort, $dir) 5119fd890c3Stracker-user { 5129fd890c3Stracker-user if (!in_array($key, $this->sortable, true)) { 5139fd890c3Stracker-user return '<th>' . hsc($label) . '</th>'; 5149fd890c3Stracker-user } 5159fd890c3Stracker-user 5169fd890c3Stracker-user $newDir = ($sort === $key && $dir === 'asc') ? 'desc' : 'asc'; 5179fd890c3Stracker-user $arrow = ''; 5189fd890c3Stracker-user if ($sort === $key) { 5199fd890c3Stracker-user // ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd) 5209fd890c3Stracker-user $arrow = ($dir === 'asc') ? ' ▲' : ' ▼'; 5219fd890c3Stracker-user } 5229fd890c3Stracker-user 5239fd890c3Stracker-user // wl() already returns an HTML-safe URL (its & separator); it must 5249fd890c3Stracker-user // NOT be passed through hsc() or the ampersands double-encode. The 5259fd890c3Stracker-user // label is plain text and IS hsc()'d. 5269fd890c3Stracker-user $url = $this->tableURL($this->standingParams(['sort' => $key, 'dir' => $newDir, 'pg' => 1])); 5279fd890c3Stracker-user return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 5289fd890c3Stracker-user } 5299fd890c3Stracker-user 5309fd890c3Stracker-user /** 5319fd890c3Stracker-user * The per-column filter row: a text input under Page, empty cells under the 5329fd890c3Stracker-user * count columns (numbers are not filtered), and the Search/Clear controls 5339fd890c3Stracker-user * under Actions. 5349fd890c3Stracker-user * 5359fd890c3Stracker-user * @param string[] $cols visible columns in order 5369fd890c3Stracker-user * @param array $filters active filters 5379fd890c3Stracker-user * @param string $sort 5389fd890c3Stracker-user * @param string $dir 5399fd890c3Stracker-user * @return string 5409fd890c3Stracker-user */ 5419fd890c3Stracker-user protected function renderFilterRow(array $cols, array $filters, $sort, $dir) 5429fd890c3Stracker-user { 5439fd890c3Stracker-user $html = '<tr class="annotations_admin_filterrow">'; 5449fd890c3Stracker-user foreach ($cols as $c) { 5459fd890c3Stracker-user if ($c === 'page') { 5469fd890c3Stracker-user $val = isset($filters['page']) ? hsc($filters['page']) : ''; 5479fd890c3Stracker-user $html .= '<td><input type="text" name="q[page]" class="edit" value="' . $val . '" /></td>'; 5489fd890c3Stracker-user } elseif ($c === 'actions') { 5499fd890c3Stracker-user $html .= '<td class="annotations_admin_filteractions">'; 5509fd890c3Stracker-user $html .= '<button type="submit" class="button">' 5519fd890c3Stracker-user . hsc($this->getLang('filter_search')) . '</button>'; 5529fd890c3Stracker-user if ($filters !== []) { 5539fd890c3Stracker-user $clear = $this->tableURL([ 5549fd890c3Stracker-user 'do' => 'admin', 5559fd890c3Stracker-user 'page' => 'annotations', 5569fd890c3Stracker-user 'sort' => $sort, 5579fd890c3Stracker-user 'dir' => $dir, 5589fd890c3Stracker-user ]); 5599fd890c3Stracker-user $html .= ' <a class="annotations_admin_clear" href="' . $clear . '">' 5609fd890c3Stracker-user . hsc($this->getLang('filter_clear')) . '</a>'; 5619fd890c3Stracker-user } 5629fd890c3Stracker-user $html .= '</td>'; 5639fd890c3Stracker-user } else { 5649fd890c3Stracker-user $html .= '<td></td>'; 5659fd890c3Stracker-user } 5669fd890c3Stracker-user } 5679fd890c3Stracker-user return $html . '</tr>'; 5689fd890c3Stracker-user } 5699fd890c3Stracker-user 5709fd890c3Stracker-user /** 5719fd890c3Stracker-user * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Empty string 5729fd890c3Stracker-user * when there is only one page. Links preserve sort + filter via standingParams. 5739fd890c3Stracker-user * 5749fd890c3Stracker-user * @param int $page 5759fd890c3Stracker-user * @param int $totalPages 5769fd890c3Stracker-user * @return string 5779fd890c3Stracker-user */ 5789fd890c3Stracker-user protected function renderPager($page, $totalPages) 5799fd890c3Stracker-user { 5809fd890c3Stracker-user if ($totalPages <= 1) { 5819fd890c3Stracker-user return ''; 5829fd890c3Stracker-user } 5839fd890c3Stracker-user 5849fd890c3Stracker-user $html = '<nav class="annotations_admin_pager" aria-label="' . hsc($this->getLang('pager_label')) . '">'; 5859fd890c3Stracker-user 5869fd890c3Stracker-user if ($page > 1) { 5879fd890c3Stracker-user $html .= $this->pagerLink($page - 1, '‹', 'pager_prev'); 5889fd890c3Stracker-user } else { 5899fd890c3Stracker-user $html .= '<span class="pager_btn pager_disabled">‹</span>'; 5909fd890c3Stracker-user } 5919fd890c3Stracker-user 5929fd890c3Stracker-user foreach ($this->pageWindow($page, $totalPages) as $p) { 5939fd890c3Stracker-user if ($p === 0) { 5949fd890c3Stracker-user $html .= '<span class="pager_gap">…</span>'; 5959fd890c3Stracker-user } elseif ($p === $page) { 5969fd890c3Stracker-user $html .= '<span class="pager_cur">' . $p . '</span>'; 5979fd890c3Stracker-user } else { 5989fd890c3Stracker-user $html .= $this->pagerLink($p, (string) $p, ''); 5999fd890c3Stracker-user } 6009fd890c3Stracker-user } 6019fd890c3Stracker-user 6029fd890c3Stracker-user if ($page < $totalPages) { 6039fd890c3Stracker-user $html .= $this->pagerLink($page + 1, '›', 'pager_next'); 6049fd890c3Stracker-user } else { 6059fd890c3Stracker-user $html .= '<span class="pager_btn pager_disabled">›</span>'; 6069fd890c3Stracker-user } 6079fd890c3Stracker-user 6089fd890c3Stracker-user return $html . '</nav>'; 6099fd890c3Stracker-user } 6109fd890c3Stracker-user 6119fd890c3Stracker-user /** 6129fd890c3Stracker-user * One pager link (number or arrow), preserving sort + filter. 6139fd890c3Stracker-user * 6149fd890c3Stracker-user * @param int $p target page 6159fd890c3Stracker-user * @param string $text already-safe link text (number or entity) 6169fd890c3Stracker-user * @param string $titleKey lang key for the title attribute, or '' for none 6179fd890c3Stracker-user * @return string 6189fd890c3Stracker-user */ 6199fd890c3Stracker-user protected function pagerLink($p, $text, $titleKey) 6209fd890c3Stracker-user { 6219fd890c3Stracker-user $url = $this->tableURL($this->standingParams(['pg' => $p])); 6229fd890c3Stracker-user $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; 6239fd890c3Stracker-user return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>'; 6249fd890c3Stracker-user } 6259fd890c3Stracker-user 6269fd890c3Stracker-user /** 6279fd890c3Stracker-user * Page numbers to show around the current page, with 0 marking an elided 6289fd890c3Stracker-user * gap. Always includes the first and last page. 6299fd890c3Stracker-user * 6309fd890c3Stracker-user * @param int $page 6319fd890c3Stracker-user * @param int $totalPages 6329fd890c3Stracker-user * @return int[] 6339fd890c3Stracker-user */ 6349fd890c3Stracker-user protected function pageWindow($page, $totalPages) 6359fd890c3Stracker-user { 6369fd890c3Stracker-user $window = 2; 6379fd890c3Stracker-user $keep = []; 6389fd890c3Stracker-user for ($i = 1; $i <= $totalPages; $i++) { 6399fd890c3Stracker-user if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { 6409fd890c3Stracker-user $keep[] = $i; 6419fd890c3Stracker-user } 6429fd890c3Stracker-user } 6439fd890c3Stracker-user 6449fd890c3Stracker-user $out = []; 6459fd890c3Stracker-user $prev = 0; 6469fd890c3Stracker-user foreach ($keep as $p) { 6479fd890c3Stracker-user if ($prev && ($p - $prev) > 1) { 6489fd890c3Stracker-user $out[] = 0; // gap marker 6499fd890c3Stracker-user } 6509fd890c3Stracker-user $out[] = $p; 6519fd890c3Stracker-user $prev = $p; 6529fd890c3Stracker-user } 6539fd890c3Stracker-user return $out; 6549fd890c3Stracker-user } 6559fd890c3Stracker-user 6569fd890c3Stracker-user // --------------------------------------------------------------------- 6579fd890c3Stracker-user // Clear-action POST forms 6589fd890c3Stracker-user // --------------------------------------------------------------------- 6599fd890c3Stracker-user 6609fd890c3Stracker-user /** 661*72d60f2dStracker-user * The four POST forms the clear buttons submit (via the HTML5 form= attr): 662*72d60f2dStracker-user * a single-page and a wiki-wide form for each of "clear resolved" and "clear 663*72d60f2dStracker-user * orphaned". For the single-page forms the page id arrives from the button's 664*72d60f2dStracker-user * value (name="clearpage"). Every form carries the CSRF token and the 665*72d60f2dStracker-user * standing sort/filter/page so the Post/Redirect/Get lands back on the same 666*72d60f2dStracker-user * view. 6679fd890c3Stracker-user * 6689fd890c3Stracker-user * @return string 6699fd890c3Stracker-user */ 6709fd890c3Stracker-user protected function clearForms() 6719fd890c3Stracker-user { 6729fd890c3Stracker-user global $ID; 6739fd890c3Stracker-user $std = $this->standingHiddenFields(); 6749fd890c3Stracker-user $base = '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 6759fd890c3Stracker-user $base .= '<input type="hidden" name="do" value="admin" />'; 6769fd890c3Stracker-user $base .= '<input type="hidden" name="page" value="annotations" />'; 6779fd890c3Stracker-user 678*72d60f2dStracker-user return $this->clearForm('ann_clear_resolved_single', 'clear_resolved', $base, $std) 679*72d60f2dStracker-user . $this->clearForm('ann_clear_resolved_all', 'clear_resolved_all', $base, $std) 680*72d60f2dStracker-user . $this->clearForm('ann_clear_orphaned_single', 'clear_orphaned', $base, $std) 681*72d60f2dStracker-user . $this->clearForm('ann_clear_orphaned_all', 'clear_orphaned_all', $base, $std); 682*72d60f2dStracker-user } 6839fd890c3Stracker-user 684*72d60f2dStracker-user /** 685*72d60f2dStracker-user * One clear-action POST form, targeted by its buttons via the HTML5 form= 686*72d60f2dStracker-user * attribute. 687*72d60f2dStracker-user * 688*72d60f2dStracker-user * @param string $formId DOM id the buttons reference 689*72d60f2dStracker-user * @param string $action value of the annotations_action field 690*72d60f2dStracker-user * @param string $base shared hidden fields (id/do/page) 691*72d60f2dStracker-user * @param string $std standing sort/filter/page hidden fields 692*72d60f2dStracker-user * @return string 693*72d60f2dStracker-user */ 694*72d60f2dStracker-user protected function clearForm($formId, $action, $base, $std) 695*72d60f2dStracker-user { 696*72d60f2dStracker-user $html = '<form id="' . hsc($formId) . '" method="post" action="' 697*72d60f2dStracker-user . DOKU_BASE . DOKU_SCRIPT . '" class="annotations_admin_post">'; 698*72d60f2dStracker-user $html .= formSecurityToken(false); 699*72d60f2dStracker-user $html .= $base; 700*72d60f2dStracker-user $html .= '<input type="hidden" name="annotations_action" value="' . hsc($action) . '" />'; 701*72d60f2dStracker-user $html .= $std; 702*72d60f2dStracker-user $html .= '</form>'; 703*72d60f2dStracker-user return $html; 7049fd890c3Stracker-user } 7059fd890c3Stracker-user 7069fd890c3Stracker-user /** 7079fd890c3Stracker-user * The standing sort/filter/page state as hidden inputs, so a clear action's 7089fd890c3Stracker-user * redirect (which rebuilds the URL from $INPUT) preserves the current view. 7099fd890c3Stracker-user * 7109fd890c3Stracker-user * @return string 7119fd890c3Stracker-user */ 7129fd890c3Stracker-user protected function standingHiddenFields() 7139fd890c3Stracker-user { 7149fd890c3Stracker-user global $INPUT; 7159fd890c3Stracker-user $html = ''; 7169fd890c3Stracker-user 7179fd890c3Stracker-user $sort = $INPUT->str('sort'); 7189fd890c3Stracker-user if (in_array($sort, $this->sortable, true)) { 7199fd890c3Stracker-user $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 7209fd890c3Stracker-user } 7219fd890c3Stracker-user if ($INPUT->str('dir') === 'desc') { 7229fd890c3Stracker-user $html .= '<input type="hidden" name="dir" value="desc" />'; 7239fd890c3Stracker-user } 7249fd890c3Stracker-user $filters = $this->activeFilters(); 7259fd890c3Stracker-user if (isset($filters['page'])) { 7269fd890c3Stracker-user $html .= '<input type="hidden" name="q[page]" value="' . hsc($filters['page']) . '" />'; 7279fd890c3Stracker-user } 7289fd890c3Stracker-user $pg = $INPUT->int('pg', 0); 7299fd890c3Stracker-user if ($pg > 1) { 7309fd890c3Stracker-user $html .= '<input type="hidden" name="pg" value="' . $pg . '" />'; 7319fd890c3Stracker-user } 7329fd890c3Stracker-user return $html; 7339fd890c3Stracker-user } 7349fd890c3Stracker-user 7359fd890c3Stracker-user /** 7369fd890c3Stracker-user * An onclick attribute that confirms before submitting, escaped safely for 7379fd890c3Stracker-user * both the HTML-attribute and the JS-string layers (json_encode escapes the 7389fd890c3Stracker-user * quotes inside the message, hsc escapes the attribute). 7399fd890c3Stracker-user * 7409fd890c3Stracker-user * @param string $key lang key of the confirmation message 7419fd890c3Stracker-user * @return string e.g. ' onclick="return confirm("…")"' 7429fd890c3Stracker-user */ 7439fd890c3Stracker-user protected function confirmAttr($key) 7449fd890c3Stracker-user { 7459fd890c3Stracker-user $js = 'return confirm(' 7469fd890c3Stracker-user . json_encode($this->getLang($key), JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) 7479fd890c3Stracker-user . ');'; 7489fd890c3Stracker-user return ' onclick="' . hsc($js) . '"'; 7499fd890c3Stracker-user } 7509fd890c3Stracker-user} 751