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