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, '&'); 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