xref: /plugin/usersettings/action.php (revision d00c9837cc1955f190d39d9e5c8adb79eb3c011d)
1<?php
2
3/**
4 * User Settings plugin — action component.
5 *
6 * Provides three things:
7 *
8 *   1. A "Preferences" item in the user menu, placed just before "Update
9 *      Profile" (via the MENU_ITEMS_ASSEMBLY event — template-independent).
10 *
11 *   2. A custom action, do=usersettings, claimed in ACTION_ACT_PREPROCESS and
12 *      rendered in TPL_ACT_UNKNOWN. This is the documented way for a plugin to
13 *      own a do= value: preventing the preprocess default makes DokuWiki route
14 *      the action through dokuwiki\Action\Plugin, which fires TPL_ACT_UNKNOWN.
15 *
16 *   3. The settings page itself: a plain HTML form of every registered toggle,
17 *      with Post/Redirect/Get handling that saves through the helper.
18 */
19
20// must be run within DokuWiki
21if (!defined('DOKU_INC')) die();
22
23use dokuwiki\Extension\ActionPlugin;
24use dokuwiki\Extension\EventHandler;
25use dokuwiki\Extension\Event;
26
27class action_plugin_usersettings extends ActionPlugin
28{
29    /** the do= value this plugin owns */
30    const ACTION = 'usersettings';
31
32    /** GET parameter that carries the user's language to the (session-less) js.php request */
33    const JS_LANG_PARAM = 'uslang';
34
35    /**
36     * The site-default language as it was *before* applyUserLang() overrode it
37     * for this request, or null when no override happened. Captured so the
38     * js.php URL builder can tell whether the user's choice actually differs
39     * from the site default (by the time it runs, $conf['lang'] is already the
40     * user's language).
41     *
42     * @var string|null
43     */
44    protected $siteDefaultLang = null;
45
46    /**
47     * Register event handlers.
48     */
49    public function register(EventHandler $controller)
50    {
51        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly');
52        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess');
53        $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown');
54
55        // Register the built-in interface language toggle.
56        $controller->register_hook(
57            helper_plugin_usersettings::REGISTER_EVENT,
58            'BEFORE',
59            $this,
60            'registerLangToggle'
61        );
62
63        // Apply the user's language choice as early as possible so that all
64        // DokuWiki rendering — including TPL_ hooks further down the chain —
65        // uses the right language strings.  ACTION_ACT_PREPROCESS fires before
66        // any output is produced and before template rendering begins.
67        $controller->register_hook(
68            'ACTION_ACT_PREPROCESS',
69            'BEFORE',
70            $this,
71            'applyUserLang',
72            null,
73            // run at priority -10 so we fire before handlePreprocess (0) and
74            // before anything else that might read $conf['lang']
75            -10
76        );
77
78        // js.php is its own request and runs with NOSESSION, so it has no
79        // REMOTE_USER and ACTION_ACT_PREPROCESS never fires for it. We therefore
80        // carry the user's language to js.php through the <script> URL: this hook
81        // (fired during the normal, authenticated page request) appends
82        // &uslang=<code> to the js.php src, which both signals the language to
83        // js.php and makes the browser cache the bundle per language.
84        $controller->register_hook(
85            'TPL_METAHEADER_OUTPUT',
86            'BEFORE',
87            $this,
88            'appendUserLangToJsUrl'
89        );
90
91        // Read the language back off the js.php URL (it survives NOSESSION) and
92        // switch $conf['lang'] before js.php loads its strings. Without this the
93        // JavaScript language bundle (LANG, LANG.plugins.*) always ships in the
94        // SITE-default language. JS_SCRIPT_LIST is the one event js.php fires
95        // before it builds its cache key and loads JS strings.
96        $controller->register_hook(
97            'JS_SCRIPT_LIST',
98            'BEFORE',
99            $this,
100            'applyUserLangToScripts'
101        );
102    }
103
104    /**
105     * Load the storage/registration helper.
106     *
107     * @return helper_plugin_usersettings|null
108     */
109    protected function getHelper()
110    {
111        /** @var helper_plugin_usersettings|null $helper */
112        $helper = plugin_load('helper', 'usersettings');
113        return $helper;
114    }
115
116    // ---------------------------------------------------------------------
117    //  1. The user-menu item
118    // ---------------------------------------------------------------------
119
120    /**
121     * Insert the "Preferences" item into the user menu, just before the
122     * "Update Profile" item.
123     *
124     * @param Event $event MENU_ITEMS_ASSEMBLY
125     * @param mixed       $param
126     */
127    public function handleMenuAssembly(Event $event, $param)
128    {
129        if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') {
130            return;
131        }
132
133        try {
134            $item = new \dokuwiki\plugin\usersettings\MenuItem();
135        } catch (\RuntimeException $e) {
136            // anonymous visitor, or the action is disabled — no menu item
137            return;
138        }
139
140        if (!isset($event->data['items']) || !is_array($event->data['items'])) {
141            return;
142        }
143        $items =& $event->data['items'];
144
145        // find the Profile item; default to appending if it is not present
146        $pos = count($items);
147        foreach ($items as $i => $existing) {
148            if ($existing instanceof \dokuwiki\Menu\Item\Profile) {
149                $pos = $i;
150                break;
151            }
152        }
153        array_splice($items, $pos, 0, [$item]);
154    }
155
156    // ---------------------------------------------------------------------
157    //  2. Claiming the custom action + handling the save
158    // ---------------------------------------------------------------------
159
160    /**
161     * Claim do=usersettings and, on a form submission, save and redirect.
162     *
163     * @param Event $event ACTION_ACT_PREPROCESS
164     * @param mixed       $param
165     */
166    public function handlePreprocess(Event $event, $param)
167    {
168        if ($event->data !== self::ACTION) {
169            return;
170        }
171
172        // Preventing the default makes DokuWiki keep the action and route it
173        // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN.
174        $event->preventDefault();
175        $event->stopPropagation();
176
177        global $INPUT, $ID;
178
179        $user = $INPUT->server->str('REMOTE_USER');
180        if ($user === '') {
181            return; // anonymous — the rendered page shows a login notice
182        }
183
184        // not a save submission — nothing to do, the page will just render
185        if (!$INPUT->post->bool('usersettings_save')) {
186            return;
187        }
188
189        // CSRF protection; checkSecurityToken() shows its own error on failure
190        if (!checkSecurityToken()) {
191            return;
192        }
193
194        $ok = $this->saveSubmittedPreferences($user);
195        msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1);
196
197        // Post/Redirect/Get: a refresh must not re-submit the form
198        send_redirect(wl($ID, ['do' => self::ACTION], true, '&'));
199    }
200
201    /**
202     * Read the submitted toggle values for every registered toggle and store
203     * them for the given user.
204     *
205     * Kept separate from handlePreprocess() so it carries no redirect and can
206     * be exercised directly by tests. Checkboxes that are unchecked do not
207     * appear in the POST data, so every registered toggle is read explicitly
208     * rather than iterating whatever was submitted.
209     *
210     * @param string      $user   whose preferences are being written
211     * @param string|null $actor  who is making the change; defaults to $user
212     *                            (the admin component passes the admin here)
213     * @return bool
214     */
215    public function saveSubmittedPreferences($user, $actor = null)
216    {
217        global $INPUT;
218
219        if ($actor === null) {
220            $actor = $user;
221        }
222
223        $helper = $this->getHelper();
224        if ($helper === null) {
225            return false;
226        }
227
228        $values = [];
229        foreach ($helper->getRegisteredToggles() as $key => $def) {
230            if ($def['type'] === 'checkbox') {
231                $values[$key] = $INPUT->post->bool($key) ? 1 : 0;
232            } else {
233                $values[$key] = $INPUT->post->str($key);
234            }
235        }
236
237        return $helper->setPreferences($values, $user, $actor);
238    }
239
240    // ---------------------------------------------------------------------
241    //  3. Rendering the settings page
242    // ---------------------------------------------------------------------
243
244    /**
245     * Render the settings page for do=usersettings.
246     *
247     * @param Event $event TPL_ACT_UNKNOWN
248     * @param mixed       $param
249     */
250    public function handleUnknown(Event $event, $param)
251    {
252        if ($event->data !== self::ACTION) {
253            return;
254        }
255        $event->preventDefault();
256        $event->stopPropagation();
257
258        echo $this->renderSettingsPage();
259    }
260
261    /**
262     * Build the HTML of the settings page.
263     *
264     * @return string
265     */
266    public function renderSettingsPage()
267    {
268        global $INPUT, $ID;
269
270        $user = $INPUT->server->str('REMOTE_USER');
271
272        $html  = '<div class="plugin_usersettings">';
273        $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>';
274
275        if ($user === '') {
276            $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>';
277            return $html . '</div>';
278        }
279
280        $helper  = $this->getHelper();
281        $toggles = $helper ? $helper->getRegisteredToggles() : [];
282
283        if (empty($toggles)) {
284            $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>';
285            return $html . '</div>';
286        }
287
288        $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>';
289
290        $action = wl($ID, ['do' => self::ACTION], false, '&amp;');
291        $html  .= '<form method="post" action="' . $action . '" class="us-form">';
292        $html  .= formSecurityToken(false);
293
294        foreach ($toggles as $key => $def) {
295            $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user));
296        }
297
298        $html .= '<div class="us-actions">';
299        $html .= '<button type="submit" name="usersettings_save" value="1" class="button">'
300               . hsc($this->getLang('save')) . '</button>';
301        $html .= '</div>';
302        $html .= '</form>';
303
304        return $html . '</div>';
305    }
306
307    // ---------------------------------------------------------------------
308    //  Built-in: interface language toggle
309    // ---------------------------------------------------------------------
310
311    /**
312     * Contribute the "Interface language" select to the usersettings registry.
313     *
314     * The option list is built by scanning DOKU_INC/inc/lang/ for sub-
315     * directories that contain a lang.php file — the same source the
316     * Configuration Manager uses for its own language drop-down.  The scan
317     * result is sorted alphabetically by language code; the site default is
318     * used as the toggle's default value so the toggle appears pre-selected
319     * correctly for users who have never changed it.
320     *
321     * @param Event $event PLUGIN_USERSETTINGS_REGISTER
322     * @param mixed       $param
323     */
324    public function registerLangToggle(Event $event, $param)
325    {
326        global $conf;
327
328        $options = $this->getAvailableLanguages();
329        if (empty($options)) {
330            return; // nothing to register if we cannot list languages
331        }
332
333        $siteDefault = $conf['lang'] ?? 'en';
334        if (!array_key_exists($siteDefault, $options)) {
335            $siteDefault = array_key_first($options);
336        }
337
338        $event->data[] = [
339            'key'     => 'lang',
340            'label'   => $this->getLang('lang_label'),
341            'desc'    => $this->getLang('lang_desc'),
342            'type'    => 'select',
343            'options' => $options,
344            'default' => $siteDefault,
345            'plugin'  => 'usersettings',
346        ];
347    }
348
349    /**
350     * Build the [code => display name] map of all installed DokuWiki interface
351     * languages by scanning inc/lang/.  The display name is the language's own
352     * native name (endonym), falling back to the bare code for any language not
353     * in the built-in map.
354     *
355     * @return array  [langCode => endonym]  sorted by language code
356     */
357    protected function getAvailableLanguages()
358    {
359        $pattern = DOKU_INC . 'inc/lang/*/lang.php';
360        $files   = glob($pattern);
361        if ($files === false || empty($files)) {
362            return [];
363        }
364
365        $langs = [];
366        foreach ($files as $file) {
367            $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php"
368            if ($code === '' || $code === '.' || $code === '..') {
369                continue;
370            }
371            $langs[$code] = $this->languageName($code);
372        }
373
374        ksort($langs, SORT_STRING);
375        return $langs;
376    }
377
378    /**
379     * Return the native name (endonym) for a language code.
380     * Falls back to the bare code for languages not in the built-in map.
381     *
382     * @param string $code ISO language code as used by DokuWiki
383     * @return string
384     */
385    protected function languageName($code)
386    {
387        $names = [
388            'af'          => 'Afrikaans',
389            'ar'          => 'العربية',
390            'az'          => 'Azərbaycan',
391            'be'          => 'Беларуская',
392            'bg'          => 'Български',
393            'bn'          => 'বাংলা',
394            'br'          => 'Brezhoneg',
395            'ca'          => 'Català',
396            'ca-valencia' => 'Català (Valencià)',
397            'ckb'         => 'کوردی سۆرانی',
398            'cs'          => 'Čeština',
399            'cy'          => 'Cymraeg',
400            'da'          => 'Dansk',
401            'de'          => 'Deutsch',
402            'de-informal' => 'Deutsch (informell)',
403            'el'          => 'Ελληνικά',
404            'en'          => 'English',
405            'eo'          => 'Esperanto',
406            'es'          => 'Español',
407            'et'          => 'Eesti',
408            'eu'          => 'Euskara',
409            'fa'          => 'فارسی',
410            'fi'          => 'Suomi',
411            'fo'          => 'Føroyskt',
412            'fr'          => 'Français',
413            'fy'          => 'Frysk',
414            'gl'          => 'Galego',
415            'he'          => 'עברית',
416            'hi'          => 'हिन्दी',
417            'hr'          => 'Hrvatski',
418            'hu'          => 'Magyar',
419            'hu-formal'   => 'Magyar (magázó)',
420            'hy'          => 'Հայերեն',
421            'ia'          => 'Interlingua',
422            'id'          => 'Bahasa Indonesia',
423            'id-ni'       => 'Bahasa Indonesia (NTT)',
424            'is'          => 'Íslenska',
425            'it'          => 'Italiano',
426            'ja'          => '日本語',
427            'ka'          => 'ქართული',
428            'kk'          => 'Қазақша',
429            'km'          => 'ភាសាខ្មែរ',
430            'kn'          => 'ಕನ್ನಡ',
431            'ko'          => '한국어',
432            'ku'          => 'Kurdî',
433            'la'          => 'Latina',
434            'lb'          => 'Lëtzebuergesch',
435            'lt'          => 'Lietuvių',
436            'lv'          => 'Latviešu',
437            'mg'          => 'Malagasy',
438            'mk'          => 'Македонски',
439            'ml'          => 'മലയാളം',
440            'mr'          => 'मराठी',
441            'ms'          => 'Bahasa Melayu',
442            'my'          => 'မြန်မာ',
443            'nan'         => '閩南語',
444            'nb'          => 'Norsk bokmål',
445            'ne'          => 'नेपाली',
446            'nl'          => 'Nederlands',
447            'nn'          => 'Nynorsk',
448            'no'          => 'Norsk',
449            'oc'          => 'Occitan',
450            'pl'          => 'Polski',
451            'pt'          => 'Português',
452            'pt-br'       => 'Português brasileiro',
453            'ro'          => 'Română',
454            'ru'          => 'Русский',
455            'si'          => 'සිංහල',
456            'sk'          => 'Slovenčina',
457            'sl'          => 'Slovenščina',
458            'sq'          => 'Shqip',
459            'sr'          => 'Српски',
460            'sv'          => 'Svenska',
461            'sw'          => 'Kiswahili',
462            'ta'          => 'தமிழ்',
463            'te'          => 'తెలుగు',
464            'th'          => 'ภาษาไทย',
465            'tr'          => 'Türkçe',
466            'uk'          => 'Українська',
467            'ur'          => 'اردو',
468            'uz'          => 'Oʻzbekcha',
469            'vi'          => 'Tiếng Việt',
470            'zh'          => '中文 (简体)',
471            'zh-tw'       => '中文 (繁體)',
472        ];
473
474        return $names[$code] ?? $code;
475    }
476
477    /**
478     * Apply the logged-in user's preferred interface language, overriding the
479     * site-wide $conf['lang'] before any rendering takes place.
480     *
481     * DokuWiki loads language strings lazily (via getLang() / $lang global
482     * reloads triggered by calls to init_lang()), so changing $conf['lang']
483     * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient
484     * to affect all subsequent output, including this plugin's own chrome.
485     *
486     * We read the stored record directly via getRecord() rather than routing
487     * through getPreference().  getPreference() fires PLUGIN_USERSETTINGS_REGISTER
488     * (and the full inc/lang/ glob) on every logged-in request before the
489     * language is known.  Reading the raw record avoids that overhead and,
490     * crucially, means the toggle registry is built *after* $conf['lang'] has
491     * been updated — so toggle labels resolve in the user's chosen language.
492     *
493     * No-op for anonymous visitors or when the user has not chosen a language
494     * that differs from the site default.
495     *
496     * @param Event $event ACTION_ACT_PREPROCESS
497     * @param mixed  $param
498     */
499    public function applyUserLang(Event $event, $param)
500    {
501        global $conf;
502
503        $preferred = $this->resolvePreferredLang();
504        if ($preferred === null) {
505            return; // anonymous, no/invalid preference, or already correct
506        }
507
508        // Remember the real site default before we override it, so the js.php
509        // URL builder (which runs after this, when $conf['lang'] is already the
510        // user's choice) can still tell the two apart.
511        $this->siteDefaultLang = $conf['lang'];
512
513        $conf['lang'] = $preferred;
514
515        // Re-initialise the global $lang array so immediately-following
516        // getLang() calls within this request pick up the new language.
517        init_lang($preferred);
518    }
519
520    /**
521     * Append the user's interface language to the lib/exe/js.php <script> URL.
522     *
523     * js.php runs with NOSESSION (no REMOTE_USER), so it cannot look the user's
524     * preference up itself — we pass it on the URL instead. The query parameter
525     * also makes the browser treat each language as a distinct resource, so the
526     * cached English bundle is not reused after a language switch.
527     *
528     * Runs in the normal page request, where the session (and the language
529     * override from applyUserLang) is available. No-op when the user has no
530     * preference, or it equals the site default.
531     *
532     * @param Event $event TPL_METAHEADER_OUTPUT ($event->data is the head array, by ref)
533     * @param mixed $param
534     */
535    public function appendUserLangToJsUrl(Event $event, $param)
536    {
537        global $conf;
538
539        $preferred = $this->getValidatedStoredLang();
540        if ($preferred === null) {
541            return;
542        }
543
544        // $conf['lang'] may already be the user's language (applyUserLang ran),
545        // so compare against the captured site default when we have it.
546        $default = $this->siteDefaultLang ?? $conf['lang'];
547        if ($preferred === $default) {
548            return;
549        }
550
551        if (empty($event->data['script']) || !is_array($event->data['script'])) {
552            return;
553        }
554
555        foreach ($event->data['script'] as $i => $script) {
556            if (!is_array($script) || empty($script['src'])) {
557                continue;
558            }
559            if (strpos($script['src'], 'lib/exe/js.php') === false) {
560                continue;
561            }
562            $event->data['script'][$i]['src'] .=
563                '&' . self::JS_LANG_PARAM . '=' . rawurlencode($preferred);
564        }
565    }
566
567    /**
568     * Apply the user's interface language to the on-the-fly JavaScript bundle
569     * (lib/exe/js.php).
570     *
571     * js.php is its own request, runs with NOSESSION (no REMOTE_USER), and never
572     * fires ACTION_ACT_PREPROCESS, so applyUserLang() does not reach it and we
573     * cannot look the user up here. The language is instead read from the
574     * &uslang= URL parameter that appendUserLangToJsUrl() put on the <script>
575     * src; it survives NOSESSION because it travels in the URL, not the session.
576     *
577     * Two things must happen here, both before js.php proceeds:
578     *
579     *   1. Switch $conf['lang'] (+ init_lang) so js_pluginstrings() and friends
580     *      read the user's language.
581     *   2. Repoint the language-specific datepicker entry already present in
582     *      the file list. js.php keys its output cache on
583     *      md5(serialize($files)); that datepicker path is the only
584     *      language-dependent member, so without rewriting it two users with
585     *      different languages would collide on a single cached bundle. js.php
586     *      skips non-existent files, so this is safe even for a language that
587     *      ships no datepicker translation.
588     *
589     * The parameter is user-controllable, so it is validated to a real
590     * inc/lang/ directory (lowercase [a-z0-9-]) before use.
591     *
592     * @param Event $event JS_SCRIPT_LIST ($event->data is the file list, by ref)
593     * @param mixed $param
594     */
595    public function applyUserLangToScripts(Event $event, $param)
596    {
597        global $conf, $INPUT;
598
599        $preferred = $INPUT->str(self::JS_LANG_PARAM);
600        if (!$this->isValidLangCode($preferred) || $preferred === $conf['lang']) {
601            return;
602        }
603
604        $old = $conf['lang'];
605
606        if (is_array($event->data)) {
607            $needle      = 'inc/lang/' . $old . '/jquery.ui.datepicker.js';
608            $replacement = 'inc/lang/' . $preferred . '/jquery.ui.datepicker.js';
609            foreach ($event->data as $i => $file) {
610                if (is_string($file) && strpos($file, $needle) !== false) {
611                    $event->data[$i] = str_replace($needle, $replacement, $file);
612                }
613            }
614        }
615
616        $conf['lang'] = $preferred;
617        init_lang($preferred);
618    }
619
620    /**
621     * Resolve the logged-in user's stored, validated interface-language
622     * preference, or null when there is none to apply.
623     *
624     * Returns null for anonymous visitors, when no preference is stored, when
625     * the stored value is malformed or names a missing inc/lang/ directory, or
626     * when it already matches the active $conf['lang']. Reads the raw stored
627     * record via getRecord() so it does NOT fire PLUGIN_USERSETTINGS_REGISTER
628     * (which would glob inc/lang/ on every request).
629     *
630     * @return string|null validated language code, or null for "leave as-is"
631     */
632    protected function resolvePreferredLang()
633    {
634        global $conf;
635
636        $preferred = $this->getValidatedStoredLang();
637        if ($preferred === null || $preferred === $conf['lang']) {
638            return null; // no preference, invalid, or already correct
639        }
640
641        return $preferred;
642    }
643
644    /**
645     * The logged-in user's stored, validated interface-language preference,
646     * irrespective of the currently active $conf['lang'].
647     *
648     * Unlike resolvePreferredLang() this does NOT short-circuit when the stored
649     * value already equals $conf['lang'] — the js.php URL builder needs the raw
650     * preference because applyUserLang() may have already switched $conf['lang']
651     * to it. Reads the raw stored record via getRecord() so it does NOT fire
652     * PLUGIN_USERSETTINGS_REGISTER (which would glob inc/lang/ on every request).
653     *
654     * @return string|null validated language code, or null when there is none
655     */
656    protected function getValidatedStoredLang()
657    {
658        global $INPUT;
659
660        $user = $INPUT->server->str('REMOTE_USER');
661        if ($user === '') {
662            return null; // anonymous — use the site default
663        }
664
665        $helper = $this->getHelper();
666        if ($helper === null) {
667            return null;
668        }
669
670        // Read the raw stored record — does not fire PLUGIN_USERSETTINGS_REGISTER.
671        $record    = $helper->getRecord('lang', $user);
672        $preferred = (is_array($record) && isset($record['value'])) ? (string) $record['value'] : null;
673
674        return $this->isValidLangCode($preferred) ? $preferred : null;
675    }
676
677    /**
678     * Whether a string is a usable interface-language code: lowercase
679     * [a-z0-9-] (defence-in-depth against path traversal) naming an existing
680     * inc/lang/ directory (so a stale or bogus code never breaks the page).
681     *
682     * @param string|null $code
683     * @return bool
684     */
685    protected function isValidLangCode($code)
686    {
687        if ($code === null || $code === '') {
688            return false;
689        }
690        if (!preg_match('/^[a-z0-9-]+$/', $code)) {
691            return false;
692        }
693        return is_dir(DOKU_INC . 'inc/lang/' . $code);
694    }
695
696    // ---------------------------------------------------------------------
697    //  Form rendering (shared between action and admin)
698    // ---------------------------------------------------------------------
699
700    /**
701     * Render one toggle as a form row. Public so the admin component can
702     * reuse it for its per-user edit form.
703     *
704     * @param array $def    a normalised toggle definition
705     * @param mixed $value  the user's effective value for this toggle
706     * @return string
707     */
708    public function renderToggleRow(array $def, $value)
709    {
710        $key = hsc($def['key']);
711
712        if ($def['type'] === 'select') {
713            $id   = 'us__' . $key;
714            $html = '<div class="us-row us-row-select">';
715            $html .= '<label class="us-label" for="' . $id . '">';
716            $html .= '<span class="us-name">' . hsc($def['label']) . '</span>';
717            $html .= '<select name="' . $key . '" id="' . $id . '">';
718            foreach ($def['options'] as $optValue => $optLabel) {
719                $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : '';
720                $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>'
721                       . hsc((string) $optLabel) . '</option>';
722            }
723            $html .= '</select>';
724            $html .= '</label>';
725        } else {
726            $checked = empty($value) ? '' : ' checked="checked"';
727            $html  = '<div class="us-row us-row-checkbox">';
728            $html .= '<label class="us-label">';
729            $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />';
730            $html .= '<span class="us-name">' . hsc($def['label']) . '</span>';
731            $html .= '</label>';
732        }
733
734        if ($def['desc'] !== '') {
735            $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>';
736        }
737
738        return $html . '</div>';
739    }
740}
741