xref: /plugin/lastseen/admin.php (revision 0c66c82e53105aa4c75f11f0c3fa59f56f42254a)
134b8413fStracker-user<?php
2e85c7321Stracker-user
3e85c7321Stracker-userif (!defined('DOKU_INC')) die();
4e85c7321Stracker-user
5e85c7321Stracker-useruse dokuwiki\Extension\AdminPlugin;
6*0c66c82eStracker-useruse dokuwiki\Utf8\PhpString;
7e85c7321Stracker-user
834b8413fStracker-user/**
934b8413fStracker-user * Last Seen plugin — admin panel page.
1034b8413fStracker-user *
1134b8413fStracker-user * Lists every registered user with the time of their last authenticated
1234b8413fStracker-user * activity. Appears in the Admin panel right after the User Manager.
13*0c66c82eStracker-user *
14*0c66c82eStracker-user * The table is sortable (any column), filterable (a per-column text-filter
15*0c66c82eStracker-user * row, substring and case-insensitive, like the User Manager but JS-free) and
16*0c66c82eStracker-user * paginated with numbered page links. Which columns appear and how many rows
17*0c66c82eStracker-user * fill a page are configurable.
1834b8413fStracker-user */
1934b8413fStracker-user
20e85c7321Stracker-userclass admin_plugin_lastseen extends AdminPlugin
2134b8413fStracker-user{
22*0c66c82eStracker-user    /** @var string[] columns that may be sorted (subject to visibility) */
23*0c66c82eStracker-user    protected $sortable = ['login', 'name', 'mail', 'grps', 'lastseen'];
24*0c66c82eStracker-user
25e85c7321Stracker-user    /**
26e85c7321Stracker-user     * Admin-only — last-seen data is mildly sensitive activity information.
27e85c7321Stracker-user     *
28e85c7321Stracker-user     * @return bool
29e85c7321Stracker-user     */
3034b8413fStracker-user    public function forAdminOnly()
3134b8413fStracker-user    {
3234b8413fStracker-user        return true;
3334b8413fStracker-user    }
3434b8413fStracker-user
35e85c7321Stracker-user    /**
36e85c7321Stracker-user     * Position in the admin menu.
37e85c7321Stracker-user     *
38e85c7321Stracker-user     * @return int
39e85c7321Stracker-user     */
4034b8413fStracker-user    public function getMenuSort()
4134b8413fStracker-user    {
42b9651948Stracker-user        return 1000;
4334b8413fStracker-user    }
4434b8413fStracker-user
45e85c7321Stracker-user    /**
46e85c7321Stracker-user     * @param string $language
47e85c7321Stracker-user     * @return string
48e85c7321Stracker-user     */
4934b8413fStracker-user    public function getMenuText($language)
5034b8413fStracker-user    {
5134b8413fStracker-user        return $this->getLang('menu');
5234b8413fStracker-user    }
5334b8413fStracker-user
54e85c7321Stracker-user    /**
55e85c7321Stracker-user     * Read-only page — no form submissions to process.
56e85c7321Stracker-user     *
57e85c7321Stracker-user     * @return void
58e85c7321Stracker-user     */
5934b8413fStracker-user    public function handle()
6034b8413fStracker-user    {
6134b8413fStracker-user    }
6234b8413fStracker-user
6334b8413fStracker-user    /**
6434b8413fStracker-user     * Render the admin page.
6534b8413fStracker-user     */
6634b8413fStracker-user    public function html()
6734b8413fStracker-user    {
6834b8413fStracker-user        global $auth, $INPUT, $ID;
6934b8413fStracker-user
7034b8413fStracker-user        echo '<h1>' . hsc($this->getLang('menu')) . '</h1>';
7134b8413fStracker-user
7234b8413fStracker-user        /** @var helper_plugin_lastseen $hlp */
7334b8413fStracker-user        $hlp = plugin_load('helper', 'lastseen');
7434b8413fStracker-user        if ($hlp === null) {
7551e72b81Stracker-user            echo '<div class="error">' . hsc($this->getLang('helper_missing')) . '</div>';
7634b8413fStracker-user            return;
7734b8413fStracker-user        }
7834b8413fStracker-user
7934b8413fStracker-user        // Some auth backends (certain LDAP/AD setups) cannot enumerate users.
8034b8413fStracker-user        // authplain can; degrade gracefully for the rest.
8134b8413fStracker-user        if (!$auth || !$auth->canDo('getUsers')) {
8234b8413fStracker-user            echo '<div class="error">' . hsc($this->getLang('no_userlist')) . '</div>';
8334b8413fStracker-user            return;
8434b8413fStracker-user        }
8534b8413fStracker-user
86*0c66c82eStracker-user        $showMail  = (bool) $this->getConf('show_mail');
87*0c66c82eStracker-user        $showGrps  = (bool) $this->getConf('show_grps');
88*0c66c82eStracker-user        $showNever = (bool) $this->getConf('show_never');
89*0c66c82eStracker-user        $perPage   = (int) $this->getConf('entries_per_page');
90*0c66c82eStracker-user
91*0c66c82eStracker-user        // visible columns, in display order
92*0c66c82eStracker-user        $cols = ['login', 'name'];
93*0c66c82eStracker-user        if ($showMail) {
94*0c66c82eStracker-user            $cols[] = 'mail';
95*0c66c82eStracker-user        }
96*0c66c82eStracker-user        if ($showGrps) {
97*0c66c82eStracker-user            $cols[] = 'grps';
98*0c66c82eStracker-user        }
99*0c66c82eStracker-user        $cols[] = 'lastseen';
100*0c66c82eStracker-user
101*0c66c82eStracker-user        // every visible column except "lastseen" is text-filterable
102*0c66c82eStracker-user        $filterCols = array_values(array_filter($cols, static function ($c) {
103*0c66c82eStracker-user            return $c !== 'lastseen';
104*0c66c82eStracker-user        }));
105*0c66c82eStracker-user
106*0c66c82eStracker-user        // ---- request parameters --------------------------------------
107*0c66c82eStracker-user        $sort = $INPUT->str('sort', 'lastseen');
108*0c66c82eStracker-user        if (!in_array($sort, $this->sortable, true)) {
109*0c66c82eStracker-user            $sort = 'lastseen';
110*0c66c82eStracker-user        }
111*0c66c82eStracker-user        // never sort by a hidden column
112*0c66c82eStracker-user        if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) {
113*0c66c82eStracker-user            $sort = 'lastseen';
114*0c66c82eStracker-user        }
115*0c66c82eStracker-user        $order   = ($INPUT->str('order', 'desc') === 'asc') ? 'asc' : 'desc';
116*0c66c82eStracker-user        $filters = $this->activeFilters($filterCols);
117*0c66c82eStracker-user
118*0c66c82eStracker-user        // ---- data ----------------------------------------------------
11934b8413fStracker-user        // retrieveUsers(0, 0): start at 0, limit 0 == all users.
12034b8413fStracker-user        // Returns [username => ['name' => ..., 'mail' => ..., 'grps' => []]].
12134b8413fStracker-user        $users = $auth->retrieveUsers(0, 0);
12234b8413fStracker-user        $seen  = $hlp->getAll();
12334b8413fStracker-user
12434b8413fStracker-user        $rows = [];
12534b8413fStracker-user        foreach ($users as $login => $info) {
12634b8413fStracker-user            $rows[] = [
12734b8413fStracker-user                'login'    => $login,
12834b8413fStracker-user                'name'     => $info['name'] ?? '',
129*0c66c82eStracker-user                'mail'     => $info['mail'] ?? '',
13034b8413fStracker-user                'grps'     => isset($info['grps']) ? implode(', ', (array) $info['grps']) : '',
13134b8413fStracker-user                'lastseen' => isset($seen[$login]) ? (int) $seen[$login] : 0, // 0 == never
13234b8413fStracker-user            ];
13334b8413fStracker-user        }
13434b8413fStracker-user
135*0c66c82eStracker-user        // "never seen" rows are dropped before filtering/paging so the counts
136*0c66c82eStracker-user        // and page numbers reflect what is actually shown.
137*0c66c82eStracker-user        if (!$showNever) {
138*0c66c82eStracker-user            $rows = array_values(array_filter($rows, static function ($r) {
139*0c66c82eStracker-user                return $r['lastseen'] !== 0;
140*0c66c82eStracker-user            }));
14134b8413fStracker-user        }
14234b8413fStracker-user
143*0c66c82eStracker-user        $rows  = $this->applyFilters($rows, $filters);
144*0c66c82eStracker-user        $rows  = $this->sortRows($rows, $sort, $order);
145*0c66c82eStracker-user        $total = count($rows);
146*0c66c82eStracker-user
147*0c66c82eStracker-user        [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage);
14834b8413fStracker-user
14934b8413fStracker-user        // ---- render --------------------------------------------------
15034b8413fStracker-user        echo '<p>' . hsc($this->getLang('intro')) . '</p>';
151*0c66c82eStracker-user
152*0c66c82eStracker-user        $labels = [
153*0c66c82eStracker-user            'login'    => $this->getLang('col_login'),
154*0c66c82eStracker-user            'name'     => $this->getLang('col_name'),
155*0c66c82eStracker-user            'mail'     => $this->getLang('col_mail'),
156*0c66c82eStracker-user            'grps'     => $this->getLang('col_grps'),
157*0c66c82eStracker-user            'lastseen' => $this->getLang('col_lastseen'),
158*0c66c82eStracker-user        ];
159*0c66c82eStracker-user
160*0c66c82eStracker-user        // GET form so the filter combines with sort links and bookmarks cleanly.
161*0c66c82eStracker-user        // The action URL's query string is dropped on submit, so every standing
162*0c66c82eStracker-user        // parameter travels as an explicit hidden field.
163*0c66c82eStracker-user        echo '<form class="lastseen_filter" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">';
164*0c66c82eStracker-user        echo '<input type="hidden" name="id" value="' . hsc($ID) . '" />';
165*0c66c82eStracker-user        echo '<input type="hidden" name="do" value="admin" />';
166*0c66c82eStracker-user        echo '<input type="hidden" name="page" value="lastseen" />';
167*0c66c82eStracker-user        echo '<input type="hidden" name="sort" value="' . hsc($sort) . '" />';
168*0c66c82eStracker-user        echo '<input type="hidden" name="order" value="' . hsc($order) . '" />';
169*0c66c82eStracker-user        echo '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1
170*0c66c82eStracker-user
17134b8413fStracker-user        echo '<div class="table">';
17234b8413fStracker-user        echo '<table class="inline plugin_lastseen">';
173*0c66c82eStracker-user        echo '<thead>';
17434b8413fStracker-user        echo '<tr>';
175*0c66c82eStracker-user        foreach ($cols as $c) {
176*0c66c82eStracker-user            echo $this->headerCell($c, $labels[$c], $sort, $order, $filters, $ID);
177*0c66c82eStracker-user        }
178*0c66c82eStracker-user        echo '</tr>';
179*0c66c82eStracker-user        echo $this->renderFilterRow($cols, $filterCols, $filters, $sort, $order, $ID);
180*0c66c82eStracker-user        echo '</thead>';
181*0c66c82eStracker-user        echo '<tbody>';
182*0c66c82eStracker-user
183*0c66c82eStracker-user        if ($total === 0) {
184*0c66c82eStracker-user            echo '<tr><td colspan="' . count($cols) . '" class="lastseen_none">'
185*0c66c82eStracker-user               . hsc($this->getLang('none')) . '</td></tr>';
186*0c66c82eStracker-user        } else {
187*0c66c82eStracker-user            foreach ($pageRows as $row) {
188*0c66c82eStracker-user                echo '<tr>';
189*0c66c82eStracker-user                foreach ($cols as $c) {
190*0c66c82eStracker-user                    if ($c !== 'lastseen') {
191*0c66c82eStracker-user                        echo '<td>' . hsc($row[$c]) . '</td>';
192*0c66c82eStracker-user                    } elseif ($row['lastseen'] === 0) {
19334b8413fStracker-user                        echo '<td class="lastseen_never">' . hsc($this->getLang('never')) . '</td>';
19434b8413fStracker-user                    } else {
19534b8413fStracker-user                        echo '<td>' . hsc(dformat($row['lastseen']))
19634b8413fStracker-user                            . ' <span class="lastseen_rel">('
19734b8413fStracker-user                            . hsc($this->relativeTime($row['lastseen'])) . ')</span></td>';
19834b8413fStracker-user                    }
199*0c66c82eStracker-user                }
20034b8413fStracker-user                echo '</tr>';
20134b8413fStracker-user            }
202*0c66c82eStracker-user        }
20334b8413fStracker-user
20434b8413fStracker-user        echo '</tbody></table></div>';
205*0c66c82eStracker-user        echo '</form>';
206*0c66c82eStracker-user
207*0c66c82eStracker-user        echo $this->renderPager($page, $totalPages, $sort, $order, $filters, $ID);
208*0c66c82eStracker-user
209*0c66c82eStracker-user        if ($total > 0) {
210*0c66c82eStracker-user            echo '<p class="lastseen_count">'
211*0c66c82eStracker-user               . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>';
212*0c66c82eStracker-user        }
213*0c66c82eStracker-user    }
214*0c66c82eStracker-user
215*0c66c82eStracker-user    // ---------------------------------------------------------------------
216*0c66c82eStracker-user    //  Filtering
217*0c66c82eStracker-user    // ---------------------------------------------------------------------
218*0c66c82eStracker-user
219*0c66c82eStracker-user    /**
220*0c66c82eStracker-user     * Read the active text filters from the request (the q[] array), keeping
221*0c66c82eStracker-user     * only the filterable columns and dropping blanks.
222*0c66c82eStracker-user     *
223*0c66c82eStracker-user     * @param string[] $filterCols column keys that accept a text filter
224*0c66c82eStracker-user     * @return array [column => trimmed search term]
225*0c66c82eStracker-user     */
226*0c66c82eStracker-user    protected function activeFilters(array $filterCols)
227*0c66c82eStracker-user    {
228*0c66c82eStracker-user        global $INPUT;
229*0c66c82eStracker-user        $raw = $INPUT->arr('q');
230*0c66c82eStracker-user        $out = [];
231*0c66c82eStracker-user        foreach ($filterCols as $c) {
232*0c66c82eStracker-user            if (isset($raw[$c]) && is_string($raw[$c])) {
233*0c66c82eStracker-user                $term = trim($raw[$c]);
234*0c66c82eStracker-user                if ($term !== '') {
235*0c66c82eStracker-user                    $out[$c] = $term;
236*0c66c82eStracker-user                }
237*0c66c82eStracker-user            }
238*0c66c82eStracker-user        }
239*0c66c82eStracker-user        return $out;
240*0c66c82eStracker-user    }
241*0c66c82eStracker-user
242*0c66c82eStracker-user    /**
243*0c66c82eStracker-user     * Keep only rows that match every active filter (substring, case-insensitive).
244*0c66c82eStracker-user     *
245*0c66c82eStracker-user     * @param array $rows
246*0c66c82eStracker-user     * @param array $filters [column => term]
247*0c66c82eStracker-user     * @return array
248*0c66c82eStracker-user     */
249*0c66c82eStracker-user    protected function applyFilters(array $rows, array $filters)
250*0c66c82eStracker-user    {
251*0c66c82eStracker-user        if ($filters === []) {
252*0c66c82eStracker-user            return $rows;
253*0c66c82eStracker-user        }
254*0c66c82eStracker-user        return array_values(array_filter($rows, function ($row) use ($filters) {
255*0c66c82eStracker-user            foreach ($filters as $col => $term) {
256*0c66c82eStracker-user                if (!$this->matches($row[$col] ?? '', $term)) {
257*0c66c82eStracker-user                    return false;
258*0c66c82eStracker-user                }
259*0c66c82eStracker-user            }
260*0c66c82eStracker-user            return true;
261*0c66c82eStracker-user        }));
262*0c66c82eStracker-user    }
263*0c66c82eStracker-user
264*0c66c82eStracker-user    /**
265*0c66c82eStracker-user     * Case-insensitive UTF-8 substring test.
266*0c66c82eStracker-user     *
267*0c66c82eStracker-user     * @param string $haystack
268*0c66c82eStracker-user     * @param string $needle
269*0c66c82eStracker-user     * @return bool
270*0c66c82eStracker-user     */
271*0c66c82eStracker-user    protected function matches($haystack, $needle)
272*0c66c82eStracker-user    {
273*0c66c82eStracker-user        if ($needle === '') {
274*0c66c82eStracker-user            return true;
275*0c66c82eStracker-user        }
276*0c66c82eStracker-user        $h = PhpString::strtolower((string) $haystack);
277*0c66c82eStracker-user        $n = PhpString::strtolower((string) $needle);
278*0c66c82eStracker-user        return PhpString::strpos($h, $n) !== false;
279*0c66c82eStracker-user    }
280*0c66c82eStracker-user
281*0c66c82eStracker-user    // ---------------------------------------------------------------------
282*0c66c82eStracker-user    //  Sorting & pagination
283*0c66c82eStracker-user    // ---------------------------------------------------------------------
284*0c66c82eStracker-user
285*0c66c82eStracker-user    /**
286*0c66c82eStracker-user     * Sort rows by the given column and direction.
287*0c66c82eStracker-user     *
288*0c66c82eStracker-user     * @param array  $rows
289*0c66c82eStracker-user     * @param string $sort  column key
290*0c66c82eStracker-user     * @param string $order 'asc' or 'desc'
291*0c66c82eStracker-user     * @return array
292*0c66c82eStracker-user     */
293*0c66c82eStracker-user    protected function sortRows(array $rows, $sort, $order)
294*0c66c82eStracker-user    {
295*0c66c82eStracker-user        usort($rows, static function ($a, $b) use ($sort) {
296*0c66c82eStracker-user            if ($sort === 'lastseen') {
297*0c66c82eStracker-user                return $a['lastseen'] <=> $b['lastseen'];
298*0c66c82eStracker-user            }
299*0c66c82eStracker-user            return strcasecmp((string) ($a[$sort] ?? ''), (string) ($b[$sort] ?? ''));
300*0c66c82eStracker-user        });
301*0c66c82eStracker-user        if ($order === 'desc') {
302*0c66c82eStracker-user            $rows = array_reverse($rows);
303*0c66c82eStracker-user        }
304*0c66c82eStracker-user        return $rows;
305*0c66c82eStracker-user    }
306*0c66c82eStracker-user
307*0c66c82eStracker-user    /**
308*0c66c82eStracker-user     * Slice the rows for the current page.
309*0c66c82eStracker-user     *
310*0c66c82eStracker-user     * @param array $rows    all rows (already filtered + sorted)
311*0c66c82eStracker-user     * @param int   $perPage rows per page; <= 0 means "all on one page"
312*0c66c82eStracker-user     * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based
313*0c66c82eStracker-user     *               row numbers of the slice (0 when there are no rows)
314*0c66c82eStracker-user     */
315*0c66c82eStracker-user    protected function paginate(array $rows, $perPage)
316*0c66c82eStracker-user    {
317*0c66c82eStracker-user        global $INPUT;
318*0c66c82eStracker-user        $total = count($rows);
319*0c66c82eStracker-user
320*0c66c82eStracker-user        if ($perPage <= 0) {
321*0c66c82eStracker-user            return [$rows, 1, 1, $total > 0 ? 1 : 0, $total];
322*0c66c82eStracker-user        }
323*0c66c82eStracker-user
324*0c66c82eStracker-user        $totalPages = max(1, (int) ceil($total / $perPage));
325*0c66c82eStracker-user        $page = $INPUT->int('pg', 1);
326*0c66c82eStracker-user        if ($page < 1) {
327*0c66c82eStracker-user            $page = 1;
328*0c66c82eStracker-user        }
329*0c66c82eStracker-user        if ($page > $totalPages) {
330*0c66c82eStracker-user            $page = $totalPages;
331*0c66c82eStracker-user        }
332*0c66c82eStracker-user
333*0c66c82eStracker-user        $offset = ($page - 1) * $perPage;
334*0c66c82eStracker-user        $slice  = array_slice($rows, $offset, $perPage);
335*0c66c82eStracker-user        $from   = $total > 0 ? $offset + 1 : 0;
336*0c66c82eStracker-user        $to     = min($total, $offset + $perPage);
337*0c66c82eStracker-user
338*0c66c82eStracker-user        return [$slice, $page, $totalPages, $from, $to];
339*0c66c82eStracker-user    }
340*0c66c82eStracker-user
341*0c66c82eStracker-user    // ---------------------------------------------------------------------
342*0c66c82eStracker-user    //  Rendering helpers
343*0c66c82eStracker-user    // ---------------------------------------------------------------------
344*0c66c82eStracker-user
345*0c66c82eStracker-user    /**
346*0c66c82eStracker-user     * Build the standing parameter set for an in-table link, with $overrides
347*0c66c82eStracker-user     * applied last. The active filters travel as the q[] array.
348*0c66c82eStracker-user     *
349*0c66c82eStracker-user     * @param array $overrides
350*0c66c82eStracker-user     * @param array $filters
351*0c66c82eStracker-user     * @return array
352*0c66c82eStracker-user     */
353*0c66c82eStracker-user    protected function linkParams(array $overrides, array $filters)
354*0c66c82eStracker-user    {
355*0c66c82eStracker-user        $params = ['do' => 'admin', 'page' => 'lastseen'];
356*0c66c82eStracker-user        if ($filters !== []) {
357*0c66c82eStracker-user            $params['q'] = $filters;
358*0c66c82eStracker-user        }
359*0c66c82eStracker-user        return array_merge($params, $overrides);
36034b8413fStracker-user    }
36134b8413fStracker-user
36234b8413fStracker-user    /**
36334b8413fStracker-user     * Emit a sortable column header. Clicking a header sorts by that column;
364*0c66c82eStracker-user     * clicking the already-active column flips the direction. The current
365*0c66c82eStracker-user     * filter is preserved and the page resets to 1.
36634b8413fStracker-user     *
36734b8413fStracker-user     * @param string $key     column key
36834b8413fStracker-user     * @param string $label   visible header text
36934b8413fStracker-user     * @param string $sort    currently active sort column
37034b8413fStracker-user     * @param string $order   currently active order (asc|desc)
371*0c66c82eStracker-user     * @param array  $filters active filters (preserved in the link)
37234b8413fStracker-user     * @param string $id      current page id (for the link target)
373*0c66c82eStracker-user     * @return string
37434b8413fStracker-user     */
375*0c66c82eStracker-user    protected function headerCell($key, $label, $sort, $order, array $filters, $id)
37634b8413fStracker-user    {
37734b8413fStracker-user        // If this column is already active, clicking flips the order;
37834b8413fStracker-user        // otherwise a fresh column starts ascending.
37934b8413fStracker-user        $newOrder = ($sort === $key && $order === 'asc') ? 'desc' : 'asc';
38034b8413fStracker-user
38134b8413fStracker-user        $arrow = '';
38234b8413fStracker-user        if ($sort === $key) {
38334b8413fStracker-user            // ▲ U+25B2 / ▼ U+25BC as HTML entities (concatenated raw, not hsc'd)
38434b8413fStracker-user            $arrow = ($order === 'asc') ? ' &#9650;' : ' &#9660;';
38534b8413fStracker-user        }
38634b8413fStracker-user
38760518ac5Stracker-user        // wl() already returns an HTML-safe URL — its default separator is the
38860518ac5Stracker-user        // pre-encoded "&amp;". It must NOT be passed through hsc(): doing so
38960518ac5Stracker-user        // double-encodes the ampersands ("&amp;" -> "&amp;amp;"), the browser
39060518ac5Stracker-user        // then navigates to a URL containing a literal "&amp;", and the query
391*0c66c82eStracker-user        // parameters arrive mis-named ("amp;sort" instead of "sort"). The label,
392*0c66c82eStracker-user        // being plain text, IS hsc()'d.
393*0c66c82eStracker-user        $url = wl($id, $this->linkParams(['sort' => $key, 'order' => $newOrder], $filters));
39434b8413fStracker-user
395*0c66c82eStracker-user        return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>';
396*0c66c82eStracker-user    }
397*0c66c82eStracker-user
398*0c66c82eStracker-user    /**
399*0c66c82eStracker-user     * Emit the per-column text-filter row: a text input under each filterable
400*0c66c82eStracker-user     * column, and the Search/Clear controls in the (non-filterable) last-seen
401*0c66c82eStracker-user     * cell.
402*0c66c82eStracker-user     *
403*0c66c82eStracker-user     * @param string[] $cols       visible columns in order
404*0c66c82eStracker-user     * @param string[] $filterCols columns that accept a text filter
405*0c66c82eStracker-user     * @param array    $filters    active filters
406*0c66c82eStracker-user     * @param string   $sort
407*0c66c82eStracker-user     * @param string   $order
408*0c66c82eStracker-user     * @param string   $id
409*0c66c82eStracker-user     * @return string
410*0c66c82eStracker-user     */
411*0c66c82eStracker-user    protected function renderFilterRow(array $cols, array $filterCols, array $filters, $sort, $order, $id)
412*0c66c82eStracker-user    {
413*0c66c82eStracker-user        $html = '<tr class="lastseen_filterrow">';
414*0c66c82eStracker-user        foreach ($cols as $c) {
415*0c66c82eStracker-user            if (in_array($c, $filterCols, true)) {
416*0c66c82eStracker-user                $val = isset($filters[$c]) ? hsc($filters[$c]) : '';
417*0c66c82eStracker-user                $html .= '<td><input type="text" name="q[' . hsc($c) . ']" class="edit" value="'
418*0c66c82eStracker-user                       . $val . '" /></td>';
419*0c66c82eStracker-user            } else {
420*0c66c82eStracker-user                // the last-seen column carries the action controls
421*0c66c82eStracker-user                $html .= '<td class="lastseen_filteractions">';
422*0c66c82eStracker-user                $html .= '<button type="submit" class="button">'
423*0c66c82eStracker-user                       . hsc($this->getLang('filter_search')) . '</button>';
424*0c66c82eStracker-user                if ($filters !== []) {
425*0c66c82eStracker-user                    $clear = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order], []));
426*0c66c82eStracker-user                    $html .= ' <a class="lastseen_clear" href="' . $clear . '">'
427*0c66c82eStracker-user                           . hsc($this->getLang('filter_clear')) . '</a>';
428*0c66c82eStracker-user                }
429*0c66c82eStracker-user                $html .= '</td>';
430*0c66c82eStracker-user            }
431*0c66c82eStracker-user        }
432*0c66c82eStracker-user        return $html . '</tr>';
433*0c66c82eStracker-user    }
434*0c66c82eStracker-user
435*0c66c82eStracker-user    /**
436*0c66c82eStracker-user     * Render the numbered pager: « prev  1 … 4 [5] 6 … 20  next ». Returns the
437*0c66c82eStracker-user     * empty string when there is only one page.
438*0c66c82eStracker-user     *
439*0c66c82eStracker-user     * @param int    $page
440*0c66c82eStracker-user     * @param int    $totalPages
441*0c66c82eStracker-user     * @param string $sort
442*0c66c82eStracker-user     * @param string $order
443*0c66c82eStracker-user     * @param array  $filters
444*0c66c82eStracker-user     * @param string $id
445*0c66c82eStracker-user     * @return string
446*0c66c82eStracker-user     */
447*0c66c82eStracker-user    protected function renderPager($page, $totalPages, $sort, $order, array $filters, $id)
448*0c66c82eStracker-user    {
449*0c66c82eStracker-user        if ($totalPages <= 1) {
450*0c66c82eStracker-user            return '';
451*0c66c82eStracker-user        }
452*0c66c82eStracker-user
453*0c66c82eStracker-user        $html = '<nav class="lastseen_pager" aria-label="' . hsc($this->getLang('pager_label')) . '">';
454*0c66c82eStracker-user
455*0c66c82eStracker-user        if ($page > 1) {
456*0c66c82eStracker-user            $html .= $this->pagerLink($id, $page - 1, $sort, $order, $filters, '&#8249;', 'pager_prev');
457*0c66c82eStracker-user        } else {
458*0c66c82eStracker-user            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
459*0c66c82eStracker-user        }
460*0c66c82eStracker-user
461*0c66c82eStracker-user        foreach ($this->pageWindow($page, $totalPages) as $p) {
462*0c66c82eStracker-user            if ($p === 0) {
463*0c66c82eStracker-user                $html .= '<span class="pager_gap">&#8230;</span>';
464*0c66c82eStracker-user            } elseif ($p === $page) {
465*0c66c82eStracker-user                $html .= '<span class="pager_cur">' . $p . '</span>';
466*0c66c82eStracker-user            } else {
467*0c66c82eStracker-user                $html .= $this->pagerLink($id, $p, $sort, $order, $filters, (string) $p, '');
468*0c66c82eStracker-user            }
469*0c66c82eStracker-user        }
470*0c66c82eStracker-user
471*0c66c82eStracker-user        if ($page < $totalPages) {
472*0c66c82eStracker-user            $html .= $this->pagerLink($id, $page + 1, $sort, $order, $filters, '&#8250;', 'pager_next');
473*0c66c82eStracker-user        } else {
474*0c66c82eStracker-user            $html .= '<span class="pager_btn pager_disabled">&#8250;</span>';
475*0c66c82eStracker-user        }
476*0c66c82eStracker-user
477*0c66c82eStracker-user        return $html . '</nav>';
478*0c66c82eStracker-user    }
479*0c66c82eStracker-user
480*0c66c82eStracker-user    /**
481*0c66c82eStracker-user     * One pager link (number or arrow), preserving sort + filter.
482*0c66c82eStracker-user     *
483*0c66c82eStracker-user     * @param string $id
484*0c66c82eStracker-user     * @param int    $p        target page
485*0c66c82eStracker-user     * @param string $sort
486*0c66c82eStracker-user     * @param string $order
487*0c66c82eStracker-user     * @param array  $filters
488*0c66c82eStracker-user     * @param string $text     already-safe link text (number or entity)
489*0c66c82eStracker-user     * @param string $titleKey lang key for the title attribute, or '' for none
490*0c66c82eStracker-user     * @return string
491*0c66c82eStracker-user     */
492*0c66c82eStracker-user    protected function pagerLink($id, $p, $sort, $order, array $filters, $text, $titleKey)
493*0c66c82eStracker-user    {
494*0c66c82eStracker-user        $url   = wl($id, $this->linkParams(['sort' => $sort, 'order' => $order, 'pg' => $p], $filters));
495*0c66c82eStracker-user        $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : '';
496*0c66c82eStracker-user        return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>';
497*0c66c82eStracker-user    }
498*0c66c82eStracker-user
499*0c66c82eStracker-user    /**
500*0c66c82eStracker-user     * Page numbers to show around the current page, with 0 marking an elided
501*0c66c82eStracker-user     * gap. Always includes the first and last page.
502*0c66c82eStracker-user     *
503*0c66c82eStracker-user     * @param int $page
504*0c66c82eStracker-user     * @param int $totalPages
505*0c66c82eStracker-user     * @return int[]
506*0c66c82eStracker-user     */
507*0c66c82eStracker-user    protected function pageWindow($page, $totalPages)
508*0c66c82eStracker-user    {
509*0c66c82eStracker-user        $window = 2;
510*0c66c82eStracker-user        $keep   = [];
511*0c66c82eStracker-user        for ($i = 1; $i <= $totalPages; $i++) {
512*0c66c82eStracker-user            if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) {
513*0c66c82eStracker-user                $keep[] = $i;
514*0c66c82eStracker-user            }
515*0c66c82eStracker-user        }
516*0c66c82eStracker-user
517*0c66c82eStracker-user        $out  = [];
518*0c66c82eStracker-user        $prev = 0;
519*0c66c82eStracker-user        foreach ($keep as $p) {
520*0c66c82eStracker-user            if ($prev && ($p - $prev) > 1) {
521*0c66c82eStracker-user                $out[] = 0; // gap marker
522*0c66c82eStracker-user            }
523*0c66c82eStracker-user            $out[] = $p;
524*0c66c82eStracker-user            $prev  = $p;
525*0c66c82eStracker-user        }
526*0c66c82eStracker-user        return $out;
52734b8413fStracker-user    }
52834b8413fStracker-user
52934b8413fStracker-user    /**
53034b8413fStracker-user     * Human-readable "time ago" string for a timestamp.
53134b8413fStracker-user     *
53234b8413fStracker-user     * @param int $timestamp
53334b8413fStracker-user     * @return string
53434b8413fStracker-user     */
53534b8413fStracker-user    protected function relativeTime($timestamp)
53634b8413fStracker-user    {
53734b8413fStracker-user        $diff = time() - $timestamp;
53834b8413fStracker-user        if ($diff < 0) {
53934b8413fStracker-user            $diff = 0;
54034b8413fStracker-user        }
54134b8413fStracker-user
54234b8413fStracker-user        if ($diff < 60) {
54334b8413fStracker-user            return $this->getLang('rel_now');
54434b8413fStracker-user        }
54534b8413fStracker-user        if ($diff < 3600) {
54651e72b81Stracker-user            $n = (int) floor($diff / 60);
54751e72b81Stracker-user            return sprintf($this->getLang($n === 1 ? 'rel_minute' : 'rel_minutes'), $n);
54834b8413fStracker-user        }
54934b8413fStracker-user        if ($diff < 86400) {
55051e72b81Stracker-user            $n = (int) floor($diff / 3600);
55151e72b81Stracker-user            return sprintf($this->getLang($n === 1 ? 'rel_hour' : 'rel_hours'), $n);
55234b8413fStracker-user        }
55334b8413fStracker-user        if ($diff < 86400 * 30) {
55451e72b81Stracker-user            $n = (int) floor($diff / 86400);
55551e72b81Stracker-user            return sprintf($this->getLang($n === 1 ? 'rel_day' : 'rel_days'), $n);
55634b8413fStracker-user        }
55734b8413fStracker-user        if ($diff < 86400 * 365) {
55851e72b81Stracker-user            $n = (int) floor($diff / (86400 * 30));
55951e72b81Stracker-user            return sprintf($this->getLang($n === 1 ? 'rel_month' : 'rel_months'), $n);
56034b8413fStracker-user        }
56151e72b81Stracker-user        $n = (int) floor($diff / (86400 * 365));
56251e72b81Stracker-user        return sprintf($this->getLang($n === 1 ? 'rel_year' : 'rel_years'), $n);
56334b8413fStracker-user    }
56434b8413fStracker-user}
565