11ab40613Stracker-user<?php 21ab40613Stracker-user 31ab40613Stracker-user/** 41ab40613Stracker-user * User Settings plugin — admin component. 51ab40613Stracker-user * 61ab40613Stracker-user * Provides an admin-only overview of every user's preferences as a flat, 71ab40613Stracker-user * sortable table: one row per (user x setting), with the columns 81ab40613Stracker-user * Display name | Setting | Value | Changed by | Changed at. 91ab40613Stracker-user * A row's value is the user's explicit choice, or the toggle's default 101ab40613Stracker-user * (marked as such) when they never set one. A filter narrows the table to a 111ab40613Stracker-user * single setting; the table never grows wider as more toggles are added. 121ab40613Stracker-user * 131ab40613Stracker-user * Clicking a display name opens a per-user edit form (Model A+: an admin may 141ab40613Stracker-user * change anyone's preferences). Such a change is stored with the admin as the 151ab40613Stracker-user * recorded actor, and the user can still change it back themselves later. 161ab40613Stracker-user */ 171ab40613Stracker-user 181ab40613Stracker-user// must be run within DokuWiki 191ab40613Stracker-userif (!defined('DOKU_INC')) die(); 201ab40613Stracker-user 2149b74e0aStracker-useruse dokuwiki\Extension\AdminPlugin; 22*54f11439Stracker-useruse dokuwiki\Utf8\PhpString; 2349b74e0aStracker-user 2449b74e0aStracker-userclass admin_plugin_usersettings extends AdminPlugin 251ab40613Stracker-user{ 261ab40613Stracker-user /** @var string[] sort key => row field used for that sort */ 271ab40613Stracker-user protected $sortFields = [ 28*54f11439Stracker-user 'login' => 'user', 291ab40613Stracker-user 'name' => 'display_name', 30*54f11439Stracker-user 'mail' => 'mail', 31*54f11439Stracker-user 'grps' => 'grps', 321ab40613Stracker-user 'setting' => 'setting_label', 331ab40613Stracker-user 'value' => 'value_display', 341ab40613Stracker-user 'changedby' => 'changed_by_display', 351ab40613Stracker-user 'changedat' => 'changed_at', 361ab40613Stracker-user ]; 371ab40613Stracker-user 381ab40613Stracker-user // --------------------------------------------------------------------- 391ab40613Stracker-user // Admin plugin metadata 401ab40613Stracker-user // --------------------------------------------------------------------- 411ab40613Stracker-user 421ab40613Stracker-user /** Only administrators may see other users' preferences. */ 431ab40613Stracker-user public function forAdminOnly() 441ab40613Stracker-user { 451ab40613Stracker-user return true; 461ab40613Stracker-user } 471ab40613Stracker-user 481ab40613Stracker-user /** Position in the admin menu. */ 491ab40613Stracker-user public function getMenuSort() 501ab40613Stracker-user { 51437b34a9Stracker-user return 1000; 521ab40613Stracker-user } 531ab40613Stracker-user 541ab40613Stracker-user /** Admin menu label — distinct from the user menu's "Preferences". */ 551ab40613Stracker-user public function getMenuText($language) 561ab40613Stracker-user { 571ab40613Stracker-user return $this->getLang('admin_menu'); 581ab40613Stracker-user } 591ab40613Stracker-user 601ab40613Stracker-user // --------------------------------------------------------------------- 611ab40613Stracker-user // Component access 621ab40613Stracker-user // --------------------------------------------------------------------- 631ab40613Stracker-user 641ab40613Stracker-user /** @return helper_plugin_usersettings|null */ 651ab40613Stracker-user protected function getHelper() 661ab40613Stracker-user { 671ab40613Stracker-user return plugin_load('helper', 'usersettings'); 681ab40613Stracker-user } 691ab40613Stracker-user 701ab40613Stracker-user /** @return action_plugin_usersettings|null */ 711ab40613Stracker-user protected function getActionPlugin() 721ab40613Stracker-user { 731ab40613Stracker-user return plugin_load('action', 'usersettings'); 741ab40613Stracker-user } 751ab40613Stracker-user 761ab40613Stracker-user // --------------------------------------------------------------------- 771ab40613Stracker-user // Request handling 781ab40613Stracker-user // --------------------------------------------------------------------- 791ab40613Stracker-user 801ab40613Stracker-user /** 811ab40613Stracker-user * Handle a submitted per-user edit form. 821ab40613Stracker-user * 831ab40613Stracker-user * Runs only for admins (DokuWiki's admin dispatcher enforces 841ab40613Stracker-user * forAdminOnly() before this is called). Uses Post/Redirect/Get. 851ab40613Stracker-user */ 861ab40613Stracker-user public function handle() 871ab40613Stracker-user { 881ab40613Stracker-user global $INPUT, $ID; 891ab40613Stracker-user 901ab40613Stracker-user if (!$INPUT->post->bool('usersettings_adminsave')) { 911ab40613Stracker-user return; 921ab40613Stracker-user } 931ab40613Stracker-user if (!checkSecurityToken()) { 941ab40613Stracker-user return; 951ab40613Stracker-user } 961ab40613Stracker-user 971ab40613Stracker-user $this->processAdminSave(); 981ab40613Stracker-user 991ab40613Stracker-user // Post/Redirect/Get back to the overview 1001ab40613Stracker-user send_redirect(wl($ID, ['do' => 'admin', 'page' => 'usersettings'], true, '&')); 1011ab40613Stracker-user } 1021ab40613Stracker-user 1031ab40613Stracker-user /** 1041ab40613Stracker-user * Validate the target user and store the submitted preferences for them, 1051ab40613Stracker-user * recording the acting admin as the actor. Kept redirect-free so it can 1061ab40613Stracker-user * be tested directly. 1071ab40613Stracker-user * 1081ab40613Stracker-user * @return bool 1091ab40613Stracker-user */ 1101ab40613Stracker-user public function processAdminSave() 1111ab40613Stracker-user { 1121ab40613Stracker-user global $INPUT, $auth; 1131ab40613Stracker-user 1141ab40613Stracker-user $target = $INPUT->post->str('edituser'); 1151ab40613Stracker-user $admin = $INPUT->server->str('REMOTE_USER'); 1161ab40613Stracker-user 1171ab40613Stracker-user $userData = ($auth !== null) ? $auth->getUserData($target) : false; 1181ab40613Stracker-user if ($userData === false) { 1191ab40613Stracker-user msg($this->getLang('badidentuser'), -1); 1201ab40613Stracker-user return false; 1211ab40613Stracker-user } 1221ab40613Stracker-user 1231ab40613Stracker-user $action = $this->getActionPlugin(); 1241ab40613Stracker-user $ok = ($action !== null) && $action->saveSubmittedPreferences($target, $admin); 1251ab40613Stracker-user 12649b74e0aStracker-user $name = (($userData['name'] ?? '') !== '') ? $userData['name'] : $target; 1271ab40613Stracker-user msg( 1281ab40613Stracker-user sprintf($this->getLang($ok ? 'adminsaved' : 'adminsavefail'), hsc($name)), 1291ab40613Stracker-user $ok ? 1 : -1 1301ab40613Stracker-user ); 1311ab40613Stracker-user return $ok; 1321ab40613Stracker-user } 1331ab40613Stracker-user 1341ab40613Stracker-user // --------------------------------------------------------------------- 1351ab40613Stracker-user // Output 1361ab40613Stracker-user // --------------------------------------------------------------------- 1371ab40613Stracker-user 1381ab40613Stracker-user /** 1391ab40613Stracker-user * Render either the overview table or, when an edituser parameter is 1401ab40613Stracker-user * present, the per-user edit form. 1411ab40613Stracker-user */ 1421ab40613Stracker-user public function html() 1431ab40613Stracker-user { 1441ab40613Stracker-user global $INPUT; 1451ab40613Stracker-user 1461ab40613Stracker-user $edituser = $INPUT->get->str('edituser'); 1471ab40613Stracker-user if ($edituser !== '') { 1481ab40613Stracker-user echo $this->renderEditForm($edituser); 1491ab40613Stracker-user } else { 1501ab40613Stracker-user echo $this->renderTable(); 1511ab40613Stracker-user } 1521ab40613Stracker-user } 1531ab40613Stracker-user 1541ab40613Stracker-user // ---- overview table -------------------------------------------------- 1551ab40613Stracker-user 1561ab40613Stracker-user /** 1571ab40613Stracker-user * Build the rows of the overview: one per (user x toggle). 1581ab40613Stracker-user * 1591ab40613Stracker-user * @param array $users [username => userdata] as from $auth->retrieveUsers() 1601ab40613Stracker-user * @param array $toggles registered toggle definitions 1611ab40613Stracker-user * @return array list of row arrays 1621ab40613Stracker-user */ 1631ab40613Stracker-user public function buildRows(array $users, array $toggles) 1641ab40613Stracker-user { 1651ab40613Stracker-user $helper = $this->getHelper(); 1661ab40613Stracker-user if ($helper === null) { 1671ab40613Stracker-user return []; 1681ab40613Stracker-user } 1691ab40613Stracker-user 1701ab40613Stracker-user $rows = []; 1711ab40613Stracker-user foreach ($users as $username => $userData) { 1721ab40613Stracker-user $displayName = (!empty($userData['name'])) ? $userData['name'] : $username; 173*54f11439Stracker-user $mail = $userData['mail'] ?? ''; 174*54f11439Stracker-user $grps = isset($userData['grps']) ? implode(', ', (array) $userData['grps']) : ''; 1751ab40613Stracker-user $stored = $helper->loadUserData($username); 1761ab40613Stracker-user 1771ab40613Stracker-user foreach ($toggles as $key => $def) { 1781ab40613Stracker-user if (isset($stored[$key]) && array_key_exists('value', $stored[$key])) { 1791ab40613Stracker-user $value = $stored[$key]['value']; 1801ab40613Stracker-user $changedBy = $stored[$key]['changed_by'] ?? ''; 1811ab40613Stracker-user $changedAt = (int) ($stored[$key]['changed_at'] ?? 0); 1821ab40613Stracker-user $isDefault = false; 1831ab40613Stracker-user } else { 1841ab40613Stracker-user $value = $def['default']; 1851ab40613Stracker-user $changedBy = ''; 1861ab40613Stracker-user $changedAt = 0; 1871ab40613Stracker-user $isDefault = true; 1881ab40613Stracker-user } 1891ab40613Stracker-user 1901ab40613Stracker-user $rows[] = [ 1911ab40613Stracker-user 'user' => $username, 1921ab40613Stracker-user 'display_name' => $displayName, 193*54f11439Stracker-user 'mail' => $mail, 194*54f11439Stracker-user 'grps' => $grps, 1951ab40613Stracker-user 'setting_key' => $key, 1961ab40613Stracker-user 'setting_label' => $def['label'], 1971ab40613Stracker-user 'value_display' => $this->displayValue($def, $value), 1981ab40613Stracker-user 'is_default' => $isDefault, 1991ab40613Stracker-user 'changed_by_display' => $isDefault ? '' : $this->resolveActor($changedBy, $users), 2001ab40613Stracker-user 'changed_at' => $changedAt, 2011ab40613Stracker-user ]; 2021ab40613Stracker-user } 2031ab40613Stracker-user } 2041ab40613Stracker-user return $rows; 2051ab40613Stracker-user } 2061ab40613Stracker-user 2071ab40613Stracker-user /** 2081ab40613Stracker-user * Sort overview rows by the given column and direction. 2091ab40613Stracker-user * 2101ab40613Stracker-user * @param array $rows 2111ab40613Stracker-user * @param string $sort one of the keys of $this->sortFields 2121ab40613Stracker-user * @param string $dir 'asc' or 'desc' 2131ab40613Stracker-user * @return array 2141ab40613Stracker-user */ 2151ab40613Stracker-user public function sortRows(array $rows, $sort, $dir) 2161ab40613Stracker-user { 2171ab40613Stracker-user $field = $this->sortFields[$sort] ?? 'display_name'; 2181ab40613Stracker-user 2191ab40613Stracker-user usort($rows, function ($a, $b) use ($field) { 2201ab40613Stracker-user if ($field === 'changed_at') { 2211ab40613Stracker-user return $a[$field] <=> $b[$field]; 2221ab40613Stracker-user } 2231ab40613Stracker-user return strcasecmp((string) $a[$field], (string) $b[$field]); 2241ab40613Stracker-user }); 2251ab40613Stracker-user 2261ab40613Stracker-user if ($dir === 'desc') { 2271ab40613Stracker-user $rows = array_reverse($rows); 2281ab40613Stracker-user } 2291ab40613Stracker-user return $rows; 2301ab40613Stracker-user } 2311ab40613Stracker-user 2321ab40613Stracker-user /** 2331ab40613Stracker-user * Human-readable value of a toggle: On/Off for a checkbox, the option 2341ab40613Stracker-user * label for a select. 2351ab40613Stracker-user * 2361ab40613Stracker-user * @param array $def 2371ab40613Stracker-user * @param mixed $value 2381ab40613Stracker-user * @return string 2391ab40613Stracker-user */ 2401ab40613Stracker-user public function displayValue(array $def, $value) 2411ab40613Stracker-user { 2421ab40613Stracker-user if ($def['type'] === 'select') { 2431ab40613Stracker-user if (isset($def['options'][$value])) { 2441ab40613Stracker-user return (string) $def['options'][$value]; 2451ab40613Stracker-user } 2461ab40613Stracker-user return (string) $value; // stored value no longer a defined option 2471ab40613Stracker-user } 2481ab40613Stracker-user return $this->getLang(empty($value) ? 'val_off' : 'val_on'); 2491ab40613Stracker-user } 2501ab40613Stracker-user 2511ab40613Stracker-user /** 2521ab40613Stracker-user * Resolve an actor username to a display name, falling back to the raw 2531ab40613Stracker-user * username when the actor is not (or no longer) a known user. 2541ab40613Stracker-user * 2551ab40613Stracker-user * @param string $actor 2561ab40613Stracker-user * @param array $users [username => userdata] 2571ab40613Stracker-user * @return string 2581ab40613Stracker-user */ 2591ab40613Stracker-user protected function resolveActor($actor, array $users) 2601ab40613Stracker-user { 2611ab40613Stracker-user if ($actor === '') { 2621ab40613Stracker-user return ''; 2631ab40613Stracker-user } 2641ab40613Stracker-user if (isset($users[$actor]) && !empty($users[$actor]['name'])) { 2651ab40613Stracker-user return $users[$actor]['name']; 2661ab40613Stracker-user } 2671ab40613Stracker-user return $actor; 2681ab40613Stracker-user } 2691ab40613Stracker-user 2701ab40613Stracker-user /** 271*54f11439Stracker-user * Render the overview table: sortable headers, a per-column text-filter 272*54f11439Stracker-user * row (plus the existing setting drop-down), the rows for the current 273*54f11439Stracker-user * page, and a numbered pager. 2741ab40613Stracker-user * 2751ab40613Stracker-user * @return string 2761ab40613Stracker-user */ 2771ab40613Stracker-user protected function renderTable() 2781ab40613Stracker-user { 279*54f11439Stracker-user global $INPUT, $auth, $ID; 2801ab40613Stracker-user 2811ab40613Stracker-user $helper = $this->getHelper(); 2821ab40613Stracker-user $toggles = $helper ? $helper->getRegisteredToggles() : []; 2831ab40613Stracker-user 2841ab40613Stracker-user $html = '<div class="plugin_usersettings_admin">'; 2851ab40613Stracker-user $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 2861ab40613Stracker-user 2871ab40613Stracker-user if (empty($toggles)) { 2881ab40613Stracker-user return $html . '<p>' . hsc($this->getLang('notoggles')) . '</p></div>'; 2891ab40613Stracker-user } 2901ab40613Stracker-user 2911ab40613Stracker-user $users = ($auth !== null) ? $auth->retrieveUsers(0, 0) : []; 2921ab40613Stracker-user if (empty($users)) { 2931ab40613Stracker-user return $html . '<p>' . hsc($this->getLang('nousers')) . '</p></div>'; 2941ab40613Stracker-user } 2951ab40613Stracker-user 296*54f11439Stracker-user $showMail = (bool) $this->getConf('show_mail'); 297*54f11439Stracker-user $showGrps = (bool) $this->getConf('show_grps'); 298*54f11439Stracker-user $perPage = (int) $this->getConf('entries_per_page'); 299*54f11439Stracker-user 300*54f11439Stracker-user // visible columns, in display order 301*54f11439Stracker-user $cols = ['login', 'name']; 302*54f11439Stracker-user if ($showMail) { 303*54f11439Stracker-user $cols[] = 'mail'; 304*54f11439Stracker-user } 305*54f11439Stracker-user if ($showGrps) { 306*54f11439Stracker-user $cols[] = 'grps'; 307*54f11439Stracker-user } 308*54f11439Stracker-user $cols = array_merge($cols, ['setting', 'value', 'changedby', 'changedat']); 309*54f11439Stracker-user 310*54f11439Stracker-user // col => row field for the text-filterable columns ("Setting" has its 311*54f11439Stracker-user // own drop-down; "Changed at" is not filterable) 312*54f11439Stracker-user $filterMap = $this->filterFieldMap($cols); 313*54f11439Stracker-user 3141ab40613Stracker-user // request parameters (sort links and the filter form are GET) 3151ab40613Stracker-user $sort = $INPUT->get->str('sort', 'name'); 3161ab40613Stracker-user if (!isset($this->sortFields[$sort])) { 3171ab40613Stracker-user $sort = 'name'; 3181ab40613Stracker-user } 319*54f11439Stracker-user if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) { 320*54f11439Stracker-user $sort = 'name'; 3211ab40613Stracker-user } 322*54f11439Stracker-user $dir = ($INPUT->get->str('dir') === 'desc') ? 'desc' : 'asc'; 323*54f11439Stracker-user 324*54f11439Stracker-user $setFilter = $INPUT->get->str('filter'); 325*54f11439Stracker-user if ($setFilter !== '' && !isset($toggles[$setFilter])) { 326*54f11439Stracker-user $setFilter = ''; 327*54f11439Stracker-user } 328*54f11439Stracker-user $qfilters = $this->activeFilters(array_keys($filterMap)); 3291ab40613Stracker-user 3301ab40613Stracker-user $html .= '<p>' . hsc($this->getLang('admin_intro')) . '</p>'; 3311ab40613Stracker-user 332*54f11439Stracker-user // build rows, narrow by the setting drop-down, then the text filters, 333*54f11439Stracker-user // then sort — all before paging so the counts and page numbers match 3341ab40613Stracker-user $rows = $this->buildRows($users, $toggles); 335*54f11439Stracker-user if ($setFilter !== '') { 336*54f11439Stracker-user $rows = array_values(array_filter($rows, static function ($r) use ($setFilter) { 337*54f11439Stracker-user return $r['setting_key'] === $setFilter; 3381ab40613Stracker-user })); 3391ab40613Stracker-user } 340*54f11439Stracker-user $rows = $this->applyFilters($rows, $qfilters, $filterMap); 3411ab40613Stracker-user $rows = $this->sortRows($rows, $sort, $dir); 342*54f11439Stracker-user $total = count($rows); 3431ab40613Stracker-user 344*54f11439Stracker-user [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage); 3451ab40613Stracker-user 346*54f11439Stracker-user $labels = [ 347*54f11439Stracker-user 'login' => $this->getLang('th_login'), 348*54f11439Stracker-user 'name' => $this->getLang('th_name'), 349*54f11439Stracker-user 'mail' => $this->getLang('th_mail'), 350*54f11439Stracker-user 'grps' => $this->getLang('th_grps'), 351*54f11439Stracker-user 'setting' => $this->getLang('th_setting'), 352*54f11439Stracker-user 'value' => $this->getLang('th_value'), 353*54f11439Stracker-user 'changedby' => $this->getLang('th_changedby'), 354*54f11439Stracker-user 'changedat' => $this->getLang('th_changedat'), 355*54f11439Stracker-user ]; 3561ab40613Stracker-user 357*54f11439Stracker-user // GET form so the filters combine with the sort links and bookmark 358*54f11439Stracker-user // cleanly; the action URL's query string is dropped on submit, so the 359*54f11439Stracker-user // standing parameters travel as hidden fields. 360*54f11439Stracker-user $html .= '<form class="us-filter-form" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 3611ab40613Stracker-user $html .= '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 3621ab40613Stracker-user $html .= '<input type="hidden" name="do" value="admin" />'; 3631ab40613Stracker-user $html .= '<input type="hidden" name="page" value="usersettings" />'; 3641ab40613Stracker-user $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 3651ab40613Stracker-user $html .= '<input type="hidden" name="dir" value="' . hsc($dir) . '" />'; 366*54f11439Stracker-user $html .= '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1 3671ab40613Stracker-user 368*54f11439Stracker-user $html .= '<div class="table">'; 369*54f11439Stracker-user $html .= '<table class="inline plugin_usersettings_table">'; 370*54f11439Stracker-user $html .= '<thead><tr>'; 371*54f11439Stracker-user foreach ($cols as $c) { 372*54f11439Stracker-user $html .= $this->sortHeader($labels[$c], $c, $sort, $dir, $setFilter, $qfilters); 3731ab40613Stracker-user } 374*54f11439Stracker-user $html .= '</tr>'; 375*54f11439Stracker-user $html .= $this->renderFilterRow($cols, $filterMap, $qfilters, $toggles, $setFilter, $sort, $dir); 376*54f11439Stracker-user $html .= '</thead><tbody>'; 377*54f11439Stracker-user 378*54f11439Stracker-user if ($total === 0) { 379*54f11439Stracker-user $html .= '<tr><td colspan="' . count($cols) . '" class="us-none">' 380*54f11439Stracker-user . hsc($this->getLang('none')) . '</td></tr>'; 381*54f11439Stracker-user } else { 382*54f11439Stracker-user foreach ($pageRows as $row) { 383*54f11439Stracker-user $rowClass = $row['is_default'] ? ' class="us-default-row"' : ''; 384*54f11439Stracker-user $html .= '<tr' . $rowClass . '>'; 385*54f11439Stracker-user foreach ($cols as $c) { 386*54f11439Stracker-user $html .= $this->bodyCell($c, $row); 387*54f11439Stracker-user } 388*54f11439Stracker-user $html .= '</tr>'; 389*54f11439Stracker-user } 390*54f11439Stracker-user } 391*54f11439Stracker-user 392*54f11439Stracker-user $html .= '</tbody></table></div>'; 393*54f11439Stracker-user $html .= '</form>'; 394*54f11439Stracker-user 395*54f11439Stracker-user $html .= $this->renderPager($page, $totalPages, $sort, $dir, $setFilter, $qfilters); 396*54f11439Stracker-user 397*54f11439Stracker-user if ($total > 0) { 398*54f11439Stracker-user $html .= '<p class="us-count">' 399*54f11439Stracker-user . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>'; 400*54f11439Stracker-user } 401*54f11439Stracker-user 402*54f11439Stracker-user return $html . '</div>'; 4031ab40613Stracker-user } 4041ab40613Stracker-user 4051ab40613Stracker-user /** 406*54f11439Stracker-user * Render one body cell for a column. 407*54f11439Stracker-user * 408*54f11439Stracker-user * @param string $c column key 409*54f11439Stracker-user * @param array $row 410*54f11439Stracker-user * @return string 411*54f11439Stracker-user */ 412*54f11439Stracker-user protected function bodyCell($c, array $row) 413*54f11439Stracker-user { 414*54f11439Stracker-user switch ($c) { 415*54f11439Stracker-user case 'login': 416*54f11439Stracker-user return '<td>' . hsc($row['user']) . '</td>'; 417*54f11439Stracker-user case 'name': 418*54f11439Stracker-user $editUrl = $this->pageURL(['edituser' => $row['user']]); 419*54f11439Stracker-user return '<td><a href="' . $editUrl . '">' . hsc($row['display_name']) . '</a></td>'; 420*54f11439Stracker-user case 'mail': 421*54f11439Stracker-user return '<td>' . hsc($row['mail']) . '</td>'; 422*54f11439Stracker-user case 'grps': 423*54f11439Stracker-user return '<td>' . hsc($row['grps']) . '</td>'; 424*54f11439Stracker-user case 'setting': 425*54f11439Stracker-user return '<td>' . hsc($row['setting_label']) . '</td>'; 426*54f11439Stracker-user case 'value': 427*54f11439Stracker-user return '<td>' . hsc($row['value_display']) . '</td>'; 428*54f11439Stracker-user case 'changedby': 429*54f11439Stracker-user return '<td>' . ($row['is_default'] 430*54f11439Stracker-user ? '<span class="us-default-mark">' . hsc($this->getLang('bydefault')) . '</span>' 431*54f11439Stracker-user : hsc($row['changed_by_display'])) . '</td>'; 432*54f11439Stracker-user case 'changedat': 433*54f11439Stracker-user return '<td>' . ($row['changed_at'] > 0 ? hsc(dformat($row['changed_at'])) : '—') . '</td>'; 434*54f11439Stracker-user default: 435*54f11439Stracker-user return '<td></td>'; 436*54f11439Stracker-user } 437*54f11439Stracker-user } 438*54f11439Stracker-user 439*54f11439Stracker-user // ---- filtering ------------------------------------------------------- 440*54f11439Stracker-user 441*54f11439Stracker-user /** 442*54f11439Stracker-user * Map of text-filterable column => row field, limited to the visible 443*54f11439Stracker-user * columns. "Setting" (drop-down) and "Changed at" are deliberately absent. 444*54f11439Stracker-user * 445*54f11439Stracker-user * @param string[] $cols visible columns 446*54f11439Stracker-user * @return array 447*54f11439Stracker-user */ 448*54f11439Stracker-user protected function filterFieldMap(array $cols) 449*54f11439Stracker-user { 450*54f11439Stracker-user $all = [ 451*54f11439Stracker-user 'login' => 'user', 452*54f11439Stracker-user 'name' => 'display_name', 453*54f11439Stracker-user 'mail' => 'mail', 454*54f11439Stracker-user 'grps' => 'grps', 455*54f11439Stracker-user 'value' => 'value_display', 456*54f11439Stracker-user 'changedby' => 'changed_by_display', 457*54f11439Stracker-user ]; 458*54f11439Stracker-user $map = []; 459*54f11439Stracker-user foreach ($cols as $c) { 460*54f11439Stracker-user if (isset($all[$c])) { 461*54f11439Stracker-user $map[$c] = $all[$c]; 462*54f11439Stracker-user } 463*54f11439Stracker-user } 464*54f11439Stracker-user return $map; 465*54f11439Stracker-user } 466*54f11439Stracker-user 467*54f11439Stracker-user /** 468*54f11439Stracker-user * Read the active text filters from the request (the q[] array), keeping 469*54f11439Stracker-user * only the given columns and dropping blanks. 470*54f11439Stracker-user * 471*54f11439Stracker-user * @param string[] $cols filterable column keys 472*54f11439Stracker-user * @return array [column => trimmed term] 473*54f11439Stracker-user */ 474*54f11439Stracker-user protected function activeFilters(array $cols) 475*54f11439Stracker-user { 476*54f11439Stracker-user global $INPUT; 477*54f11439Stracker-user $raw = $INPUT->arr('q'); 478*54f11439Stracker-user $out = []; 479*54f11439Stracker-user foreach ($cols as $c) { 480*54f11439Stracker-user if (isset($raw[$c]) && is_string($raw[$c])) { 481*54f11439Stracker-user $term = trim($raw[$c]); 482*54f11439Stracker-user if ($term !== '') { 483*54f11439Stracker-user $out[$c] = $term; 484*54f11439Stracker-user } 485*54f11439Stracker-user } 486*54f11439Stracker-user } 487*54f11439Stracker-user return $out; 488*54f11439Stracker-user } 489*54f11439Stracker-user 490*54f11439Stracker-user /** 491*54f11439Stracker-user * Keep only rows matching every active text filter (substring, 492*54f11439Stracker-user * case-insensitive). 493*54f11439Stracker-user * 494*54f11439Stracker-user * @param array $rows 495*54f11439Stracker-user * @param array $qfilters [column => term] 496*54f11439Stracker-user * @param array $map [column => row field] 497*54f11439Stracker-user * @return array 498*54f11439Stracker-user */ 499*54f11439Stracker-user protected function applyFilters(array $rows, array $qfilters, array $map) 500*54f11439Stracker-user { 501*54f11439Stracker-user if ($qfilters === []) { 502*54f11439Stracker-user return $rows; 503*54f11439Stracker-user } 504*54f11439Stracker-user return array_values(array_filter($rows, function ($row) use ($qfilters, $map) { 505*54f11439Stracker-user foreach ($qfilters as $col => $term) { 506*54f11439Stracker-user $field = $map[$col] ?? null; 507*54f11439Stracker-user if ($field === null) { 508*54f11439Stracker-user continue; 509*54f11439Stracker-user } 510*54f11439Stracker-user if (!$this->matches($row[$field] ?? '', $term)) { 511*54f11439Stracker-user return false; 512*54f11439Stracker-user } 513*54f11439Stracker-user } 514*54f11439Stracker-user return true; 515*54f11439Stracker-user })); 516*54f11439Stracker-user } 517*54f11439Stracker-user 518*54f11439Stracker-user /** 519*54f11439Stracker-user * Case-insensitive UTF-8 substring test. 520*54f11439Stracker-user * 521*54f11439Stracker-user * @param string $haystack 522*54f11439Stracker-user * @param string $needle 523*54f11439Stracker-user * @return bool 524*54f11439Stracker-user */ 525*54f11439Stracker-user protected function matches($haystack, $needle) 526*54f11439Stracker-user { 527*54f11439Stracker-user if ($needle === '') { 528*54f11439Stracker-user return true; 529*54f11439Stracker-user } 530*54f11439Stracker-user $h = PhpString::strtolower((string) $haystack); 531*54f11439Stracker-user $n = PhpString::strtolower((string) $needle); 532*54f11439Stracker-user return PhpString::strpos($h, $n) !== false; 533*54f11439Stracker-user } 534*54f11439Stracker-user 535*54f11439Stracker-user // ---- pagination ------------------------------------------------------ 536*54f11439Stracker-user 537*54f11439Stracker-user /** 538*54f11439Stracker-user * Slice the rows for the current page. 539*54f11439Stracker-user * 540*54f11439Stracker-user * @param array $rows filtered + sorted rows 541*54f11439Stracker-user * @param int $perPage rows per page; <= 0 means "all on one page" 542*54f11439Stracker-user * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based 543*54f11439Stracker-user * row numbers of the slice (0 when there are no rows) 544*54f11439Stracker-user */ 545*54f11439Stracker-user protected function paginate(array $rows, $perPage) 546*54f11439Stracker-user { 547*54f11439Stracker-user global $INPUT; 548*54f11439Stracker-user $total = count($rows); 549*54f11439Stracker-user 550*54f11439Stracker-user if ($perPage <= 0) { 551*54f11439Stracker-user return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; 552*54f11439Stracker-user } 553*54f11439Stracker-user 554*54f11439Stracker-user $totalPages = max(1, (int) ceil($total / $perPage)); 555*54f11439Stracker-user $page = $INPUT->int('pg', 1); 556*54f11439Stracker-user if ($page < 1) { 557*54f11439Stracker-user $page = 1; 558*54f11439Stracker-user } 559*54f11439Stracker-user if ($page > $totalPages) { 560*54f11439Stracker-user $page = $totalPages; 561*54f11439Stracker-user } 562*54f11439Stracker-user 563*54f11439Stracker-user $offset = ($page - 1) * $perPage; 564*54f11439Stracker-user $slice = array_slice($rows, $offset, $perPage); 565*54f11439Stracker-user $from = $total > 0 ? $offset + 1 : 0; 566*54f11439Stracker-user $to = min($total, $offset + $perPage); 567*54f11439Stracker-user 568*54f11439Stracker-user return [$slice, $page, $totalPages, $from, $to]; 569*54f11439Stracker-user } 570*54f11439Stracker-user 571*54f11439Stracker-user // ---- link + header + filter-row + pager helpers ---------------------- 572*54f11439Stracker-user 573*54f11439Stracker-user /** 574*54f11439Stracker-user * The standing query parameters every in-table link must carry: sort, 575*54f11439Stracker-user * direction, the setting drop-down and the active text filters. 576*54f11439Stracker-user * 577*54f11439Stracker-user * @param string $sort 578*54f11439Stracker-user * @param string $dir 579*54f11439Stracker-user * @param string $setFilter 580*54f11439Stracker-user * @param array $qfilters 581*54f11439Stracker-user * @return array 582*54f11439Stracker-user */ 583*54f11439Stracker-user protected function standingParams($sort, $dir, $setFilter, array $qfilters) 584*54f11439Stracker-user { 585*54f11439Stracker-user $params = ['sort' => $sort, 'dir' => $dir]; 586*54f11439Stracker-user if ($setFilter !== '') { 587*54f11439Stracker-user $params['filter'] = $setFilter; 588*54f11439Stracker-user } 589*54f11439Stracker-user if ($qfilters !== []) { 590*54f11439Stracker-user $params['q'] = $qfilters; 591*54f11439Stracker-user } 592*54f11439Stracker-user return $params; 593*54f11439Stracker-user } 594*54f11439Stracker-user 595*54f11439Stracker-user /** 596*54f11439Stracker-user * Build a URL back to this admin page with the given query parameters. 597*54f11439Stracker-user * 598*54f11439Stracker-user * @param array $params 599*54f11439Stracker-user * @return string HTML-attribute-safe URL 600*54f11439Stracker-user */ 601*54f11439Stracker-user protected function tableURL(array $params) 602*54f11439Stracker-user { 603*54f11439Stracker-user global $ID; 604*54f11439Stracker-user $base = ['do' => 'admin', 'page' => 'usersettings']; 605*54f11439Stracker-user return wl($ID, array_merge($base, $params), false, '&'); 606*54f11439Stracker-user } 607*54f11439Stracker-user 608*54f11439Stracker-user /** 609*54f11439Stracker-user * Render one sortable column header. Clicking the active column flips the 610*54f11439Stracker-user * direction; the drop-down filter and text filters are preserved and the 611*54f11439Stracker-user * page resets to 1. 6121ab40613Stracker-user * 6131ab40613Stracker-user * @param string $label 6141ab40613Stracker-user * @param string $col sort key for this column 6151ab40613Stracker-user * @param string $sort currently active sort key 6161ab40613Stracker-user * @param string $dir currently active direction 617*54f11439Stracker-user * @param string $setFilter currently selected setting key 618*54f11439Stracker-user * @param array $qfilters active text filters 6191ab40613Stracker-user * @return string 6201ab40613Stracker-user */ 621*54f11439Stracker-user protected function sortHeader($label, $col, $sort, $dir, $setFilter, array $qfilters) 6221ab40613Stracker-user { 6231ab40613Stracker-user // clicking the active column flips direction; others start ascending 6241ab40613Stracker-user $newDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc'; 6251ab40613Stracker-user $arrow = ''; 6261ab40613Stracker-user if ($sort === $col) { 6271ab40613Stracker-user $arrow = ($dir === 'asc') ? " \u{25B2}" : " \u{25BC}"; 6281ab40613Stracker-user } 6291ab40613Stracker-user 630*54f11439Stracker-user $url = $this->tableURL(array_merge( 631*54f11439Stracker-user $this->standingParams($sort, $dir, $setFilter, $qfilters), 632*54f11439Stracker-user ['sort' => $col, 'dir' => $newDir] 633*54f11439Stracker-user )); 6341ab40613Stracker-user return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 6351ab40613Stracker-user } 6361ab40613Stracker-user 637*54f11439Stracker-user /** 638*54f11439Stracker-user * The per-column filter row: a text input under each text-filterable 639*54f11439Stracker-user * column, the existing setting drop-down under "Setting", and the 640*54f11439Stracker-user * Search/Clear controls under the (non-filterable) "Changed at" column. 641*54f11439Stracker-user * 642*54f11439Stracker-user * @param string[] $cols visible columns in order 643*54f11439Stracker-user * @param array $filterMap col => row field for text-filterable columns 644*54f11439Stracker-user * @param array $qfilters active text filters 645*54f11439Stracker-user * @param array $toggles registered toggles (for the drop-down) 646*54f11439Stracker-user * @param string $setFilter currently selected setting key 647*54f11439Stracker-user * @param string $sort 648*54f11439Stracker-user * @param string $dir 649*54f11439Stracker-user * @return string 650*54f11439Stracker-user */ 651*54f11439Stracker-user protected function renderFilterRow(array $cols, array $filterMap, array $qfilters, array $toggles, $setFilter, $sort, $dir) 652*54f11439Stracker-user { 653*54f11439Stracker-user $html = '<tr class="us-filterrow">'; 654*54f11439Stracker-user foreach ($cols as $c) { 655*54f11439Stracker-user if ($c === 'setting') { 656*54f11439Stracker-user $html .= '<td><select name="filter" title="' . hsc($this->getLang('filter_label')) . '">'; 657*54f11439Stracker-user $html .= '<option value="">' . hsc($this->getLang('filter_all')) . '</option>'; 658*54f11439Stracker-user foreach ($toggles as $key => $def) { 659*54f11439Stracker-user $selected = ($setFilter === $key) ? ' selected="selected"' : ''; 660*54f11439Stracker-user $html .= '<option value="' . hsc($key) . '"' . $selected . '>' 661*54f11439Stracker-user . hsc($def['label']) . '</option>'; 662*54f11439Stracker-user } 663*54f11439Stracker-user $html .= '</select></td>'; 664*54f11439Stracker-user } elseif ($c === 'changedat') { 665*54f11439Stracker-user $html .= '<td class="us-filteractions">'; 666*54f11439Stracker-user $html .= '<button type="submit" class="button">' 667*54f11439Stracker-user . hsc($this->getLang('filter_search')) . '</button>'; 668*54f11439Stracker-user if ($setFilter !== '' || $qfilters !== []) { 669*54f11439Stracker-user $clear = $this->tableURL(['sort' => $sort, 'dir' => $dir]); 670*54f11439Stracker-user $html .= ' <a class="us-clear" href="' . $clear . '">' 671*54f11439Stracker-user . hsc($this->getLang('filter_clear')) . '</a>'; 672*54f11439Stracker-user } 673*54f11439Stracker-user $html .= '</td>'; 674*54f11439Stracker-user } elseif (isset($filterMap[$c])) { 675*54f11439Stracker-user $val = isset($qfilters[$c]) ? hsc($qfilters[$c]) : ''; 676*54f11439Stracker-user $html .= '<td><input type="text" name="q[' . hsc($c) . ']" class="edit" value="' 677*54f11439Stracker-user . $val . '" /></td>'; 678*54f11439Stracker-user } else { 679*54f11439Stracker-user $html .= '<td></td>'; 680*54f11439Stracker-user } 681*54f11439Stracker-user } 682*54f11439Stracker-user return $html . '</tr>'; 683*54f11439Stracker-user } 684*54f11439Stracker-user 685*54f11439Stracker-user /** 686*54f11439Stracker-user * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Returns the 687*54f11439Stracker-user * empty string when there is only one page. 688*54f11439Stracker-user * 689*54f11439Stracker-user * @param int $page 690*54f11439Stracker-user * @param int $totalPages 691*54f11439Stracker-user * @param string $sort 692*54f11439Stracker-user * @param string $dir 693*54f11439Stracker-user * @param string $setFilter 694*54f11439Stracker-user * @param array $qfilters 695*54f11439Stracker-user * @return string 696*54f11439Stracker-user */ 697*54f11439Stracker-user protected function renderPager($page, $totalPages, $sort, $dir, $setFilter, array $qfilters) 698*54f11439Stracker-user { 699*54f11439Stracker-user if ($totalPages <= 1) { 700*54f11439Stracker-user return ''; 701*54f11439Stracker-user } 702*54f11439Stracker-user 703*54f11439Stracker-user $html = '<nav class="us-pager" aria-label="' . hsc($this->getLang('pager_label')) . '">'; 704*54f11439Stracker-user 705*54f11439Stracker-user if ($page > 1) { 706*54f11439Stracker-user $html .= $this->pagerLink($page - 1, $sort, $dir, $setFilter, $qfilters, '‹', 'pager_prev'); 707*54f11439Stracker-user } else { 708*54f11439Stracker-user $html .= '<span class="pager_btn pager_disabled">‹</span>'; 709*54f11439Stracker-user } 710*54f11439Stracker-user 711*54f11439Stracker-user foreach ($this->pageWindow($page, $totalPages) as $p) { 712*54f11439Stracker-user if ($p === 0) { 713*54f11439Stracker-user $html .= '<span class="pager_gap">…</span>'; 714*54f11439Stracker-user } elseif ($p === $page) { 715*54f11439Stracker-user $html .= '<span class="pager_cur">' . $p . '</span>'; 716*54f11439Stracker-user } else { 717*54f11439Stracker-user $html .= $this->pagerLink($p, $sort, $dir, $setFilter, $qfilters, (string) $p, ''); 718*54f11439Stracker-user } 719*54f11439Stracker-user } 720*54f11439Stracker-user 721*54f11439Stracker-user if ($page < $totalPages) { 722*54f11439Stracker-user $html .= $this->pagerLink($page + 1, $sort, $dir, $setFilter, $qfilters, '›', 'pager_next'); 723*54f11439Stracker-user } else { 724*54f11439Stracker-user $html .= '<span class="pager_btn pager_disabled">›</span>'; 725*54f11439Stracker-user } 726*54f11439Stracker-user 727*54f11439Stracker-user return $html . '</nav>'; 728*54f11439Stracker-user } 729*54f11439Stracker-user 730*54f11439Stracker-user /** 731*54f11439Stracker-user * One pager link (number or arrow), preserving sort + filters. 732*54f11439Stracker-user * 733*54f11439Stracker-user * @param int $p target page 734*54f11439Stracker-user * @param string $sort 735*54f11439Stracker-user * @param string $dir 736*54f11439Stracker-user * @param string $setFilter 737*54f11439Stracker-user * @param array $qfilters 738*54f11439Stracker-user * @param string $text already-safe link text (number or entity) 739*54f11439Stracker-user * @param string $titleKey lang key for the title attribute, or '' for none 740*54f11439Stracker-user * @return string 741*54f11439Stracker-user */ 742*54f11439Stracker-user protected function pagerLink($p, $sort, $dir, $setFilter, array $qfilters, $text, $titleKey) 743*54f11439Stracker-user { 744*54f11439Stracker-user $url = $this->tableURL(array_merge( 745*54f11439Stracker-user $this->standingParams($sort, $dir, $setFilter, $qfilters), 746*54f11439Stracker-user ['pg' => $p] 747*54f11439Stracker-user )); 748*54f11439Stracker-user $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; 749*54f11439Stracker-user return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>'; 750*54f11439Stracker-user } 751*54f11439Stracker-user 752*54f11439Stracker-user /** 753*54f11439Stracker-user * Page numbers around the current page, 0 marking an elided gap; always 754*54f11439Stracker-user * includes the first and last page. 755*54f11439Stracker-user * 756*54f11439Stracker-user * @param int $page 757*54f11439Stracker-user * @param int $totalPages 758*54f11439Stracker-user * @return int[] 759*54f11439Stracker-user */ 760*54f11439Stracker-user protected function pageWindow($page, $totalPages) 761*54f11439Stracker-user { 762*54f11439Stracker-user $window = 2; 763*54f11439Stracker-user $keep = []; 764*54f11439Stracker-user for ($i = 1; $i <= $totalPages; $i++) { 765*54f11439Stracker-user if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { 766*54f11439Stracker-user $keep[] = $i; 767*54f11439Stracker-user } 768*54f11439Stracker-user } 769*54f11439Stracker-user 770*54f11439Stracker-user $out = []; 771*54f11439Stracker-user $prev = 0; 772*54f11439Stracker-user foreach ($keep as $p) { 773*54f11439Stracker-user if ($prev && ($p - $prev) > 1) { 774*54f11439Stracker-user $out[] = 0; // gap marker 775*54f11439Stracker-user } 776*54f11439Stracker-user $out[] = $p; 777*54f11439Stracker-user $prev = $p; 778*54f11439Stracker-user } 779*54f11439Stracker-user return $out; 780*54f11439Stracker-user } 781*54f11439Stracker-user 7821ab40613Stracker-user // ---- per-user edit form --------------------------------------------- 7831ab40613Stracker-user 7841ab40613Stracker-user /** 7851ab40613Stracker-user * Render the edit form for one user's preferences. 7861ab40613Stracker-user * 7871ab40613Stracker-user * @param string $user 7881ab40613Stracker-user * @return string 7891ab40613Stracker-user */ 7901ab40613Stracker-user protected function renderEditForm($user) 7911ab40613Stracker-user { 7921ab40613Stracker-user global $auth, $ID; 7931ab40613Stracker-user 7948f16c88bStracker-user $html = '<div class="plugin_usersettings_admin plugin_usersettings">'; 7951ab40613Stracker-user 7961ab40613Stracker-user $userData = ($auth !== null) ? $auth->getUserData($user) : false; 7971ab40613Stracker-user if ($userData === false) { 7981ab40613Stracker-user $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 7991ab40613Stracker-user $html .= '<p>' . hsc($this->getLang('badidentuser')) . '</p>'; 8001ab40613Stracker-user $html .= '<p><a href="' . $this->pageURL() . '">' 8011ab40613Stracker-user . hsc($this->getLang('edit_back')) . '</a></p>'; 8021ab40613Stracker-user return $html . '</div>'; 8031ab40613Stracker-user } 8041ab40613Stracker-user 80549b74e0aStracker-user $displayName = (($userData['name'] ?? '') !== '') ? $userData['name'] : $user; 8061ab40613Stracker-user $html .= '<h1>' . hsc(sprintf($this->getLang('edit_heading'), $displayName)) . '</h1>'; 8071ab40613Stracker-user 8081ab40613Stracker-user $helper = $this->getHelper(); 8091ab40613Stracker-user $action = $this->getActionPlugin(); 8101ab40613Stracker-user $toggles = $helper ? $helper->getRegisteredToggles() : []; 8111ab40613Stracker-user 8121ab40613Stracker-user if (empty($toggles) || $action === null) { 8131ab40613Stracker-user $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 8141ab40613Stracker-user $html .= '<p><a href="' . $this->pageURL() . '">' 8151ab40613Stracker-user . hsc($this->getLang('edit_back')) . '</a></p>'; 8161ab40613Stracker-user return $html . '</div>'; 8171ab40613Stracker-user } 8181ab40613Stracker-user 8191ab40613Stracker-user $formAction = wl($ID, ['do' => 'admin', 'page' => 'usersettings'], false, '&'); 8201ab40613Stracker-user $html .= '<form method="post" action="' . $formAction . '" class="us-form">'; 8211ab40613Stracker-user $html .= formSecurityToken(false); 8221ab40613Stracker-user $html .= '<input type="hidden" name="edituser" value="' . hsc($user) . '" />'; 8231ab40613Stracker-user $html .= '<input type="hidden" name="usersettings_adminsave" value="1" />'; 8241ab40613Stracker-user 8251ab40613Stracker-user foreach ($toggles as $key => $def) { 8261ab40613Stracker-user $html .= $action->renderToggleRow($def, $helper->getPreference($key, $user)); 8271ab40613Stracker-user } 8281ab40613Stracker-user 8291ab40613Stracker-user $html .= '<div class="us-actions">'; 8301ab40613Stracker-user $html .= '<button type="submit" class="button">' 8311ab40613Stracker-user . hsc($this->getLang('save')) . '</button> '; 8321ab40613Stracker-user $html .= '<a href="' . $this->pageURL() . '" class="us-back">' 8331ab40613Stracker-user . hsc($this->getLang('edit_back')) . '</a>'; 8341ab40613Stracker-user $html .= '</div>'; 8351ab40613Stracker-user $html .= '</form>'; 8361ab40613Stracker-user 8371ab40613Stracker-user return $html . '</div>'; 8381ab40613Stracker-user } 8391ab40613Stracker-user 8401ab40613Stracker-user // ---- helpers --------------------------------------------------------- 8411ab40613Stracker-user 8421ab40613Stracker-user /** 8431ab40613Stracker-user * Build a URL back to this admin page with the given extra parameters. 8441ab40613Stracker-user * 8451ab40613Stracker-user * @param array $params 8461ab40613Stracker-user * @return string HTML-attribute-safe URL 8471ab40613Stracker-user */ 8481ab40613Stracker-user protected function pageURL(array $params = []) 8491ab40613Stracker-user { 8501ab40613Stracker-user global $ID; 8511ab40613Stracker-user $base = ['do' => 'admin', 'page' => 'usersettings']; 8521ab40613Stracker-user return wl($ID, array_merge($base, $params), false, '&'); 8531ab40613Stracker-user } 8541ab40613Stracker-user} 855