xref: /plugin/usersettings/admin.php (revision 54f11439a68c5f07eddd4c7f20a09acd149bde82)
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'])) : '&mdash;') . '</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, '&amp;');
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, '&#8249;', 'pager_prev');
707        } else {
708            $html .= '<span class="pager_btn pager_disabled">&#8249;</span>';
709        }
710
711        foreach ($this->pageWindow($page, $totalPages) as $p) {
712            if ($p === 0) {
713                $html .= '<span class="pager_gap">&#8230;</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, '&#8250;', 'pager_next');
723        } else {
724            $html .= '<span class="pager_btn pager_disabled">&#8250;</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, '&amp;');
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, '&amp;');
853    }
854}
855