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') ? ' ▲' : ' ▼'; 38534b8413fStracker-user } 38634b8413fStracker-user 38760518ac5Stracker-user // wl() already returns an HTML-safe URL — its default separator is the 38860518ac5Stracker-user // pre-encoded "&". It must NOT be passed through hsc(): doing so 38960518ac5Stracker-user // double-encodes the ampersands ("&" -> "&amp;"), the browser 39060518ac5Stracker-user // then navigates to a URL containing a literal "&", 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, '‹', 'pager_prev'); 457*0c66c82eStracker-user } else { 458*0c66c82eStracker-user $html .= '<span class="pager_btn pager_disabled">‹</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">…</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, '›', 'pager_next'); 473*0c66c82eStracker-user } else { 474*0c66c82eStracker-user $html .= '<span class="pager_btn pager_disabled">›</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