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