xref: /plugin/annotations/admin.php (revision 9fd890c3d28899ac6132f5f0d76a031cc5c27f1a)
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, '&amp;');
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') ? ' &#9650;' : ' &#9660;';
488        }
489
490        // wl() already returns an HTML-safe URL (its &amp; 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, '&#8249;', 'pager_prev');
555        } else {
556            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
557        }
558
559        foreach ($this->pageWindow($page, $totalPages) as $p) {
560            if ($p === 0) {
561                $html .= '<span class="pager_gap">&#8230;</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, '&#8250;', 'pager_next');
571        } else {
572            $html .= '<span class="pager_btn pager_disabled">&#8250;</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(&quot;…&quot;)"'
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