xref: /plugin/annotations/admin.php (revision 86c3281af18ac3f82557b4466adb5aef413e1639)
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, '&amp;');
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') ? ' &#9650;' : ' &#9660;';
521        }
522
523        // wl() already returns an HTML-safe URL (its &amp; 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, '&#8249;', 'pager_prev');
588        } else {
589            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
590        }
591
592        foreach ($this->pageWindow($page, $totalPages) as $p) {
593            if ($p === 0) {
594                $html .= '<span class="pager_gap">&#8230;</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, '&#8250;', 'pager_next');
604        } else {
605            $html .= '<span class="pager_btn pager_disabled">&#8250;</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(&quot;…&quot;)"'
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