1<?php 2 3/** 4 * User Settings plugin — admin component. 5 * 6 * Provides an admin-only overview of every user's preferences as a flat, 7 * sortable table: one row per (user x setting), with the columns 8 * Display name | Setting | Value | Changed by | Changed at. 9 * A row's value is the user's explicit choice, or the toggle's default 10 * (marked as such) when they never set one. A filter narrows the table to a 11 * single setting; the table never grows wider as more toggles are added. 12 * 13 * Clicking a display name opens a per-user edit form (Model A+: an admin may 14 * change anyone's preferences). Such a change is stored with the admin as the 15 * recorded actor, and the user can still change it back themselves later. 16 */ 17 18// must be run within DokuWiki 19if (!defined('DOKU_INC')) die(); 20 21use dokuwiki\Extension\AdminPlugin; 22use dokuwiki\Utf8\PhpString; 23 24class admin_plugin_usersettings extends AdminPlugin 25{ 26 /** @var string[] sort key => row field used for that sort */ 27 protected $sortFields = [ 28 'login' => 'user', 29 'name' => 'display_name', 30 'mail' => 'mail', 31 'grps' => 'grps', 32 'setting' => 'setting_label', 33 'value' => 'value_display', 34 'changedby' => 'changed_by_display', 35 'changedat' => 'changed_at', 36 ]; 37 38 // --------------------------------------------------------------------- 39 // Admin plugin metadata 40 // --------------------------------------------------------------------- 41 42 /** Only administrators may see other users' preferences. */ 43 public function forAdminOnly() 44 { 45 return true; 46 } 47 48 /** Position in the admin menu. */ 49 public function getMenuSort() 50 { 51 return 1000; 52 } 53 54 /** Admin menu label — distinct from the user menu's "Preferences". */ 55 public function getMenuText($language) 56 { 57 return $this->getLang('admin_menu'); 58 } 59 60 // --------------------------------------------------------------------- 61 // Component access 62 // --------------------------------------------------------------------- 63 64 /** @return helper_plugin_usersettings|null */ 65 protected function getHelper() 66 { 67 return plugin_load('helper', 'usersettings'); 68 } 69 70 /** @return action_plugin_usersettings|null */ 71 protected function getActionPlugin() 72 { 73 return plugin_load('action', 'usersettings'); 74 } 75 76 // --------------------------------------------------------------------- 77 // Request handling 78 // --------------------------------------------------------------------- 79 80 /** 81 * Handle a submitted per-user edit form. 82 * 83 * Runs only for admins (DokuWiki's admin dispatcher enforces 84 * forAdminOnly() before this is called). Uses Post/Redirect/Get. 85 */ 86 public function handle() 87 { 88 global $INPUT, $ID; 89 90 if (!$INPUT->post->bool('usersettings_adminsave')) { 91 return; 92 } 93 if (!checkSecurityToken()) { 94 return; 95 } 96 97 $this->processAdminSave(); 98 99 // Post/Redirect/Get back to the overview 100 send_redirect(wl($ID, ['do' => 'admin', 'page' => 'usersettings'], true, '&')); 101 } 102 103 /** 104 * Validate the target user and store the submitted preferences for them, 105 * recording the acting admin as the actor. Kept redirect-free so it can 106 * be tested directly. 107 * 108 * @return bool 109 */ 110 public function processAdminSave() 111 { 112 global $INPUT, $auth; 113 114 $target = $INPUT->post->str('edituser'); 115 $admin = $INPUT->server->str('REMOTE_USER'); 116 117 $userData = ($auth !== null) ? $auth->getUserData($target) : false; 118 if ($userData === false) { 119 msg($this->getLang('badidentuser'), -1); 120 return false; 121 } 122 123 $action = $this->getActionPlugin(); 124 $ok = ($action !== null) && $action->saveSubmittedPreferences($target, $admin); 125 126 $name = (($userData['name'] ?? '') !== '') ? $userData['name'] : $target; 127 msg( 128 sprintf($this->getLang($ok ? 'adminsaved' : 'adminsavefail'), hsc($name)), 129 $ok ? 1 : -1 130 ); 131 return $ok; 132 } 133 134 // --------------------------------------------------------------------- 135 // Output 136 // --------------------------------------------------------------------- 137 138 /** 139 * Render either the overview table or, when an edituser parameter is 140 * present, the per-user edit form. 141 */ 142 public function html() 143 { 144 global $INPUT; 145 146 $edituser = $INPUT->get->str('edituser'); 147 if ($edituser !== '') { 148 echo $this->renderEditForm($edituser); 149 } else { 150 echo $this->renderTable(); 151 } 152 } 153 154 // ---- overview table -------------------------------------------------- 155 156 /** 157 * Build the rows of the overview: one per (user x toggle). 158 * 159 * @param array $users [username => userdata] as from $auth->retrieveUsers() 160 * @param array $toggles registered toggle definitions 161 * @return array list of row arrays 162 */ 163 public function buildRows(array $users, array $toggles) 164 { 165 $helper = $this->getHelper(); 166 if ($helper === null) { 167 return []; 168 } 169 170 $rows = []; 171 foreach ($users as $username => $userData) { 172 $displayName = (!empty($userData['name'])) ? $userData['name'] : $username; 173 $mail = $userData['mail'] ?? ''; 174 $grps = isset($userData['grps']) ? implode(', ', (array) $userData['grps']) : ''; 175 $stored = $helper->loadUserData($username); 176 177 foreach ($toggles as $key => $def) { 178 if (isset($stored[$key]) && array_key_exists('value', $stored[$key])) { 179 $value = $stored[$key]['value']; 180 $changedBy = $stored[$key]['changed_by'] ?? ''; 181 $changedAt = (int) ($stored[$key]['changed_at'] ?? 0); 182 $isDefault = false; 183 } else { 184 $value = $def['default']; 185 $changedBy = ''; 186 $changedAt = 0; 187 $isDefault = true; 188 } 189 190 $rows[] = [ 191 'user' => $username, 192 'display_name' => $displayName, 193 'mail' => $mail, 194 'grps' => $grps, 195 'setting_key' => $key, 196 'setting_label' => $def['label'], 197 'value_display' => $this->displayValue($def, $value), 198 'is_default' => $isDefault, 199 'changed_by_display' => $isDefault ? '' : $this->resolveActor($changedBy, $users), 200 'changed_at' => $changedAt, 201 ]; 202 } 203 } 204 return $rows; 205 } 206 207 /** 208 * Sort overview rows by the given column and direction. 209 * 210 * @param array $rows 211 * @param string $sort one of the keys of $this->sortFields 212 * @param string $dir 'asc' or 'desc' 213 * @return array 214 */ 215 public function sortRows(array $rows, $sort, $dir) 216 { 217 $field = $this->sortFields[$sort] ?? 'display_name'; 218 219 usort($rows, function ($a, $b) use ($field) { 220 if ($field === 'changed_at') { 221 return $a[$field] <=> $b[$field]; 222 } 223 return strcasecmp((string) $a[$field], (string) $b[$field]); 224 }); 225 226 if ($dir === 'desc') { 227 $rows = array_reverse($rows); 228 } 229 return $rows; 230 } 231 232 /** 233 * Human-readable value of a toggle: On/Off for a checkbox, the option 234 * label for a select. 235 * 236 * @param array $def 237 * @param mixed $value 238 * @return string 239 */ 240 public function displayValue(array $def, $value) 241 { 242 if ($def['type'] === 'select') { 243 if (isset($def['options'][$value])) { 244 return (string) $def['options'][$value]; 245 } 246 return (string) $value; // stored value no longer a defined option 247 } 248 return $this->getLang(empty($value) ? 'val_off' : 'val_on'); 249 } 250 251 /** 252 * Resolve an actor username to a display name, falling back to the raw 253 * username when the actor is not (or no longer) a known user. 254 * 255 * @param string $actor 256 * @param array $users [username => userdata] 257 * @return string 258 */ 259 protected function resolveActor($actor, array $users) 260 { 261 if ($actor === '') { 262 return ''; 263 } 264 if (isset($users[$actor]) && !empty($users[$actor]['name'])) { 265 return $users[$actor]['name']; 266 } 267 return $actor; 268 } 269 270 /** 271 * Render the overview table: sortable headers, a per-column text-filter 272 * row (plus the existing setting drop-down), the rows for the current 273 * page, and a numbered pager. 274 * 275 * @return string 276 */ 277 protected function renderTable() 278 { 279 global $INPUT, $auth, $ID; 280 281 $helper = $this->getHelper(); 282 $toggles = $helper ? $helper->getRegisteredToggles() : []; 283 284 $html = '<div class="plugin_usersettings_admin">'; 285 $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 286 287 if (empty($toggles)) { 288 return $html . '<p>' . hsc($this->getLang('notoggles')) . '</p></div>'; 289 } 290 291 $users = ($auth !== null) ? $auth->retrieveUsers(0, 0) : []; 292 if (empty($users)) { 293 return $html . '<p>' . hsc($this->getLang('nousers')) . '</p></div>'; 294 } 295 296 $showMail = (bool) $this->getConf('show_mail'); 297 $showGrps = (bool) $this->getConf('show_grps'); 298 $perPage = (int) $this->getConf('entries_per_page'); 299 300 // visible columns, in display order 301 $cols = ['login', 'name']; 302 if ($showMail) { 303 $cols[] = 'mail'; 304 } 305 if ($showGrps) { 306 $cols[] = 'grps'; 307 } 308 $cols = array_merge($cols, ['setting', 'value', 'changedby', 'changedat']); 309 310 // col => row field for the text-filterable columns ("Setting" has its 311 // own drop-down; "Changed at" is not filterable) 312 $filterMap = $this->filterFieldMap($cols); 313 314 // request parameters (sort links and the filter form are GET) 315 $sort = $INPUT->get->str('sort', 'name'); 316 if (!isset($this->sortFields[$sort])) { 317 $sort = 'name'; 318 } 319 if (($sort === 'mail' && !$showMail) || ($sort === 'grps' && !$showGrps)) { 320 $sort = 'name'; 321 } 322 $dir = ($INPUT->get->str('dir') === 'desc') ? 'desc' : 'asc'; 323 324 $setFilter = $INPUT->get->str('filter'); 325 if ($setFilter !== '' && !isset($toggles[$setFilter])) { 326 $setFilter = ''; 327 } 328 $qfilters = $this->activeFilters(array_keys($filterMap)); 329 330 $html .= '<p>' . hsc($this->getLang('admin_intro')) . '</p>'; 331 332 // build rows, narrow by the setting drop-down, then the text filters, 333 // then sort — all before paging so the counts and page numbers match 334 $rows = $this->buildRows($users, $toggles); 335 if ($setFilter !== '') { 336 $rows = array_values(array_filter($rows, static function ($r) use ($setFilter) { 337 return $r['setting_key'] === $setFilter; 338 })); 339 } 340 $rows = $this->applyFilters($rows, $qfilters, $filterMap); 341 $rows = $this->sortRows($rows, $sort, $dir); 342 $total = count($rows); 343 344 [$pageRows, $page, $totalPages, $from, $to] = $this->paginate($rows, $perPage); 345 346 $labels = [ 347 'login' => $this->getLang('th_login'), 348 'name' => $this->getLang('th_name'), 349 'mail' => $this->getLang('th_mail'), 350 'grps' => $this->getLang('th_grps'), 351 'setting' => $this->getLang('th_setting'), 352 'value' => $this->getLang('th_value'), 353 'changedby' => $this->getLang('th_changedby'), 354 'changedat' => $this->getLang('th_changedat'), 355 ]; 356 357 // GET form so the filters combine with the sort links and bookmark 358 // cleanly; the action URL's query string is dropped on submit, so the 359 // standing parameters travel as hidden fields. 360 $html .= '<form class="us-filter-form" method="get" action="' . DOKU_BASE . DOKU_SCRIPT . '">'; 361 $html .= '<input type="hidden" name="id" value="' . hsc($ID) . '" />'; 362 $html .= '<input type="hidden" name="do" value="admin" />'; 363 $html .= '<input type="hidden" name="page" value="usersettings" />'; 364 $html .= '<input type="hidden" name="sort" value="' . hsc($sort) . '" />'; 365 $html .= '<input type="hidden" name="dir" value="' . hsc($dir) . '" />'; 366 $html .= '<input type="hidden" name="pg" value="1" />'; // a new search lands on page 1 367 368 $html .= '<div class="table">'; 369 $html .= '<table class="inline plugin_usersettings_table">'; 370 $html .= '<thead><tr>'; 371 foreach ($cols as $c) { 372 $html .= $this->sortHeader($labels[$c], $c, $sort, $dir, $setFilter, $qfilters); 373 } 374 $html .= '</tr>'; 375 $html .= $this->renderFilterRow($cols, $filterMap, $qfilters, $toggles, $setFilter, $sort, $dir); 376 $html .= '</thead><tbody>'; 377 378 if ($total === 0) { 379 $html .= '<tr><td colspan="' . count($cols) . '" class="us-none">' 380 . hsc($this->getLang('none')) . '</td></tr>'; 381 } else { 382 foreach ($pageRows as $row) { 383 $rowClass = $row['is_default'] ? ' class="us-default-row"' : ''; 384 $html .= '<tr' . $rowClass . '>'; 385 foreach ($cols as $c) { 386 $html .= $this->bodyCell($c, $row); 387 } 388 $html .= '</tr>'; 389 } 390 } 391 392 $html .= '</tbody></table></div>'; 393 $html .= '</form>'; 394 395 $html .= $this->renderPager($page, $totalPages, $sort, $dir, $setFilter, $qfilters); 396 397 if ($total > 0) { 398 $html .= '<p class="us-count">' 399 . hsc(sprintf($this->getLang('shown'), $from, $to, $total)) . '</p>'; 400 } 401 402 return $html . '</div>'; 403 } 404 405 /** 406 * Render one body cell for a column. 407 * 408 * @param string $c column key 409 * @param array $row 410 * @return string 411 */ 412 protected function bodyCell($c, array $row) 413 { 414 switch ($c) { 415 case 'login': 416 return '<td>' . hsc($row['user']) . '</td>'; 417 case 'name': 418 $editUrl = $this->pageURL(['edituser' => $row['user']]); 419 return '<td><a href="' . $editUrl . '">' . hsc($row['display_name']) . '</a></td>'; 420 case 'mail': 421 return '<td>' . hsc($row['mail']) . '</td>'; 422 case 'grps': 423 return '<td>' . hsc($row['grps']) . '</td>'; 424 case 'setting': 425 return '<td>' . hsc($row['setting_label']) . '</td>'; 426 case 'value': 427 return '<td>' . hsc($row['value_display']) . '</td>'; 428 case 'changedby': 429 return '<td>' . ($row['is_default'] 430 ? '<span class="us-default-mark">' . hsc($this->getLang('bydefault')) . '</span>' 431 : hsc($row['changed_by_display'])) . '</td>'; 432 case 'changedat': 433 return '<td>' . ($row['changed_at'] > 0 ? hsc(dformat($row['changed_at'])) : '—') . '</td>'; 434 default: 435 return '<td></td>'; 436 } 437 } 438 439 // ---- filtering ------------------------------------------------------- 440 441 /** 442 * Map of text-filterable column => row field, limited to the visible 443 * columns. "Setting" (drop-down) and "Changed at" are deliberately absent. 444 * 445 * @param string[] $cols visible columns 446 * @return array 447 */ 448 protected function filterFieldMap(array $cols) 449 { 450 $all = [ 451 'login' => 'user', 452 'name' => 'display_name', 453 'mail' => 'mail', 454 'grps' => 'grps', 455 'value' => 'value_display', 456 'changedby' => 'changed_by_display', 457 ]; 458 $map = []; 459 foreach ($cols as $c) { 460 if (isset($all[$c])) { 461 $map[$c] = $all[$c]; 462 } 463 } 464 return $map; 465 } 466 467 /** 468 * Read the active text filters from the request (the q[] array), keeping 469 * only the given columns and dropping blanks. 470 * 471 * @param string[] $cols filterable column keys 472 * @return array [column => trimmed term] 473 */ 474 protected function activeFilters(array $cols) 475 { 476 global $INPUT; 477 $raw = $INPUT->arr('q'); 478 $out = []; 479 foreach ($cols as $c) { 480 if (isset($raw[$c]) && is_string($raw[$c])) { 481 $term = trim($raw[$c]); 482 if ($term !== '') { 483 $out[$c] = $term; 484 } 485 } 486 } 487 return $out; 488 } 489 490 /** 491 * Keep only rows matching every active text filter (substring, 492 * case-insensitive). 493 * 494 * @param array $rows 495 * @param array $qfilters [column => term] 496 * @param array $map [column => row field] 497 * @return array 498 */ 499 protected function applyFilters(array $rows, array $qfilters, array $map) 500 { 501 if ($qfilters === []) { 502 return $rows; 503 } 504 return array_values(array_filter($rows, function ($row) use ($qfilters, $map) { 505 foreach ($qfilters as $col => $term) { 506 $field = $map[$col] ?? null; 507 if ($field === null) { 508 continue; 509 } 510 if (!$this->matches($row[$field] ?? '', $term)) { 511 return false; 512 } 513 } 514 return true; 515 })); 516 } 517 518 /** 519 * Case-insensitive UTF-8 substring test. 520 * 521 * @param string $haystack 522 * @param string $needle 523 * @return bool 524 */ 525 protected function matches($haystack, $needle) 526 { 527 if ($needle === '') { 528 return true; 529 } 530 $h = PhpString::strtolower((string) $haystack); 531 $n = PhpString::strtolower((string) $needle); 532 return PhpString::strpos($h, $n) !== false; 533 } 534 535 // ---- pagination ------------------------------------------------------ 536 537 /** 538 * Slice the rows for the current page. 539 * 540 * @param array $rows filtered + sorted rows 541 * @param int $perPage rows per page; <= 0 means "all on one page" 542 * @return array [pageRows, page, totalPages, from, to] — from/to are 1-based 543 * row numbers of the slice (0 when there are no rows) 544 */ 545 protected function paginate(array $rows, $perPage) 546 { 547 global $INPUT; 548 $total = count($rows); 549 550 if ($perPage <= 0) { 551 return [$rows, 1, 1, $total > 0 ? 1 : 0, $total]; 552 } 553 554 $totalPages = max(1, (int) ceil($total / $perPage)); 555 $page = $INPUT->int('pg', 1); 556 if ($page < 1) { 557 $page = 1; 558 } 559 if ($page > $totalPages) { 560 $page = $totalPages; 561 } 562 563 $offset = ($page - 1) * $perPage; 564 $slice = array_slice($rows, $offset, $perPage); 565 $from = $total > 0 ? $offset + 1 : 0; 566 $to = min($total, $offset + $perPage); 567 568 return [$slice, $page, $totalPages, $from, $to]; 569 } 570 571 // ---- link + header + filter-row + pager helpers ---------------------- 572 573 /** 574 * The standing query parameters every in-table link must carry: sort, 575 * direction, the setting drop-down and the active text filters. 576 * 577 * @param string $sort 578 * @param string $dir 579 * @param string $setFilter 580 * @param array $qfilters 581 * @return array 582 */ 583 protected function standingParams($sort, $dir, $setFilter, array $qfilters) 584 { 585 $params = ['sort' => $sort, 'dir' => $dir]; 586 if ($setFilter !== '') { 587 $params['filter'] = $setFilter; 588 } 589 if ($qfilters !== []) { 590 $params['q'] = $qfilters; 591 } 592 return $params; 593 } 594 595 /** 596 * Build a URL back to this admin page with the given query parameters. 597 * 598 * @param array $params 599 * @return string HTML-attribute-safe URL 600 */ 601 protected function tableURL(array $params) 602 { 603 global $ID; 604 $base = ['do' => 'admin', 'page' => 'usersettings']; 605 return wl($ID, array_merge($base, $params), false, '&'); 606 } 607 608 /** 609 * Render one sortable column header. Clicking the active column flips the 610 * direction; the drop-down filter and text filters are preserved and the 611 * page resets to 1. 612 * 613 * @param string $label 614 * @param string $col sort key for this column 615 * @param string $sort currently active sort key 616 * @param string $dir currently active direction 617 * @param string $setFilter currently selected setting key 618 * @param array $qfilters active text filters 619 * @return string 620 */ 621 protected function sortHeader($label, $col, $sort, $dir, $setFilter, array $qfilters) 622 { 623 // clicking the active column flips direction; others start ascending 624 $newDir = ($sort === $col && $dir === 'asc') ? 'desc' : 'asc'; 625 $arrow = ''; 626 if ($sort === $col) { 627 $arrow = ($dir === 'asc') ? " \u{25B2}" : " \u{25BC}"; 628 } 629 630 $url = $this->tableURL(array_merge( 631 $this->standingParams($sort, $dir, $setFilter, $qfilters), 632 ['sort' => $col, 'dir' => $newDir] 633 )); 634 return '<th><a href="' . $url . '">' . hsc($label) . $arrow . '</a></th>'; 635 } 636 637 /** 638 * The per-column filter row: a text input under each text-filterable 639 * column, the existing setting drop-down under "Setting", and the 640 * Search/Clear controls under the (non-filterable) "Changed at" column. 641 * 642 * @param string[] $cols visible columns in order 643 * @param array $filterMap col => row field for text-filterable columns 644 * @param array $qfilters active text filters 645 * @param array $toggles registered toggles (for the drop-down) 646 * @param string $setFilter currently selected setting key 647 * @param string $sort 648 * @param string $dir 649 * @return string 650 */ 651 protected function renderFilterRow(array $cols, array $filterMap, array $qfilters, array $toggles, $setFilter, $sort, $dir) 652 { 653 $html = '<tr class="us-filterrow">'; 654 foreach ($cols as $c) { 655 if ($c === 'setting') { 656 $html .= '<td><select name="filter" title="' . hsc($this->getLang('filter_label')) . '">'; 657 $html .= '<option value="">' . hsc($this->getLang('filter_all')) . '</option>'; 658 foreach ($toggles as $key => $def) { 659 $selected = ($setFilter === $key) ? ' selected="selected"' : ''; 660 $html .= '<option value="' . hsc($key) . '"' . $selected . '>' 661 . hsc($def['label']) . '</option>'; 662 } 663 $html .= '</select></td>'; 664 } elseif ($c === 'changedat') { 665 $html .= '<td class="us-filteractions">'; 666 $html .= '<button type="submit" class="button">' 667 . hsc($this->getLang('filter_search')) . '</button>'; 668 if ($setFilter !== '' || $qfilters !== []) { 669 $clear = $this->tableURL(['sort' => $sort, 'dir' => $dir]); 670 $html .= ' <a class="us-clear" href="' . $clear . '">' 671 . hsc($this->getLang('filter_clear')) . '</a>'; 672 } 673 $html .= '</td>'; 674 } elseif (isset($filterMap[$c])) { 675 $val = isset($qfilters[$c]) ? hsc($qfilters[$c]) : ''; 676 $html .= '<td><input type="text" name="q[' . hsc($c) . ']" class="edit" value="' 677 . $val . '" /></td>'; 678 } else { 679 $html .= '<td></td>'; 680 } 681 } 682 return $html . '</tr>'; 683 } 684 685 /** 686 * Render the numbered pager: « prev 1 … 4 [5] 6 … 20 next ». Returns the 687 * empty string when there is only one page. 688 * 689 * @param int $page 690 * @param int $totalPages 691 * @param string $sort 692 * @param string $dir 693 * @param string $setFilter 694 * @param array $qfilters 695 * @return string 696 */ 697 protected function renderPager($page, $totalPages, $sort, $dir, $setFilter, array $qfilters) 698 { 699 if ($totalPages <= 1) { 700 return ''; 701 } 702 703 $html = '<nav class="us-pager" aria-label="' . hsc($this->getLang('pager_label')) . '">'; 704 705 if ($page > 1) { 706 $html .= $this->pagerLink($page - 1, $sort, $dir, $setFilter, $qfilters, '‹', 'pager_prev'); 707 } else { 708 $html .= '<span class="pager_btn pager_disabled">‹</span>'; 709 } 710 711 foreach ($this->pageWindow($page, $totalPages) as $p) { 712 if ($p === 0) { 713 $html .= '<span class="pager_gap">…</span>'; 714 } elseif ($p === $page) { 715 $html .= '<span class="pager_cur">' . $p . '</span>'; 716 } else { 717 $html .= $this->pagerLink($p, $sort, $dir, $setFilter, $qfilters, (string) $p, ''); 718 } 719 } 720 721 if ($page < $totalPages) { 722 $html .= $this->pagerLink($page + 1, $sort, $dir, $setFilter, $qfilters, '›', 'pager_next'); 723 } else { 724 $html .= '<span class="pager_btn pager_disabled">›</span>'; 725 } 726 727 return $html . '</nav>'; 728 } 729 730 /** 731 * One pager link (number or arrow), preserving sort + filters. 732 * 733 * @param int $p target page 734 * @param string $sort 735 * @param string $dir 736 * @param string $setFilter 737 * @param array $qfilters 738 * @param string $text already-safe link text (number or entity) 739 * @param string $titleKey lang key for the title attribute, or '' for none 740 * @return string 741 */ 742 protected function pagerLink($p, $sort, $dir, $setFilter, array $qfilters, $text, $titleKey) 743 { 744 $url = $this->tableURL(array_merge( 745 $this->standingParams($sort, $dir, $setFilter, $qfilters), 746 ['pg' => $p] 747 )); 748 $title = ($titleKey !== '') ? ' title="' . hsc($this->getLang($titleKey)) . '"' : ''; 749 return '<a class="pager_btn" href="' . $url . '"' . $title . '>' . $text . '</a>'; 750 } 751 752 /** 753 * Page numbers around the current page, 0 marking an elided gap; always 754 * includes the first and last page. 755 * 756 * @param int $page 757 * @param int $totalPages 758 * @return int[] 759 */ 760 protected function pageWindow($page, $totalPages) 761 { 762 $window = 2; 763 $keep = []; 764 for ($i = 1; $i <= $totalPages; $i++) { 765 if ($i === 1 || $i === $totalPages || ($i >= $page - $window && $i <= $page + $window)) { 766 $keep[] = $i; 767 } 768 } 769 770 $out = []; 771 $prev = 0; 772 foreach ($keep as $p) { 773 if ($prev && ($p - $prev) > 1) { 774 $out[] = 0; // gap marker 775 } 776 $out[] = $p; 777 $prev = $p; 778 } 779 return $out; 780 } 781 782 // ---- per-user edit form --------------------------------------------- 783 784 /** 785 * Render the edit form for one user's preferences. 786 * 787 * @param string $user 788 * @return string 789 */ 790 protected function renderEditForm($user) 791 { 792 global $auth, $ID; 793 794 $html = '<div class="plugin_usersettings_admin plugin_usersettings">'; 795 796 $userData = ($auth !== null) ? $auth->getUserData($user) : false; 797 if ($userData === false) { 798 $html .= '<h1>' . hsc($this->getLang('admin_heading')) . '</h1>'; 799 $html .= '<p>' . hsc($this->getLang('badidentuser')) . '</p>'; 800 $html .= '<p><a href="' . $this->pageURL() . '">' 801 . hsc($this->getLang('edit_back')) . '</a></p>'; 802 return $html . '</div>'; 803 } 804 805 $displayName = (($userData['name'] ?? '') !== '') ? $userData['name'] : $user; 806 $html .= '<h1>' . hsc(sprintf($this->getLang('edit_heading'), $displayName)) . '</h1>'; 807 808 $helper = $this->getHelper(); 809 $action = $this->getActionPlugin(); 810 $toggles = $helper ? $helper->getRegisteredToggles() : []; 811 812 if (empty($toggles) || $action === null) { 813 $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 814 $html .= '<p><a href="' . $this->pageURL() . '">' 815 . hsc($this->getLang('edit_back')) . '</a></p>'; 816 return $html . '</div>'; 817 } 818 819 $formAction = wl($ID, ['do' => 'admin', 'page' => 'usersettings'], false, '&'); 820 $html .= '<form method="post" action="' . $formAction . '" class="us-form">'; 821 $html .= formSecurityToken(false); 822 $html .= '<input type="hidden" name="edituser" value="' . hsc($user) . '" />'; 823 $html .= '<input type="hidden" name="usersettings_adminsave" value="1" />'; 824 825 foreach ($toggles as $key => $def) { 826 $html .= $action->renderToggleRow($def, $helper->getPreference($key, $user)); 827 } 828 829 $html .= '<div class="us-actions">'; 830 $html .= '<button type="submit" class="button">' 831 . hsc($this->getLang('save')) . '</button> '; 832 $html .= '<a href="' . $this->pageURL() . '" class="us-back">' 833 . hsc($this->getLang('edit_back')) . '</a>'; 834 $html .= '</div>'; 835 $html .= '</form>'; 836 837 return $html . '</div>'; 838 } 839 840 // ---- helpers --------------------------------------------------------- 841 842 /** 843 * Build a URL back to this admin page with the given extra parameters. 844 * 845 * @param array $params 846 * @return string HTML-attribute-safe URL 847 */ 848 protected function pageURL(array $params = []) 849 { 850 global $ID; 851 $base = ['do' => 'admin', 'page' => 'usersettings']; 852 return wl($ID, array_merge($base, $params), false, '&'); 853 } 854} 855