11ab40613Stracker-user<?php 21ab40613Stracker-user 31ab40613Stracker-user/** 41ab40613Stracker-user * User Settings plugin — action component. 51ab40613Stracker-user * 61ab40613Stracker-user * Provides three things: 71ab40613Stracker-user * 81ab40613Stracker-user * 1. A "Preferences" item in the user menu, placed just before "Update 91ab40613Stracker-user * Profile" (via the MENU_ITEMS_ASSEMBLY event — template-independent). 101ab40613Stracker-user * 111ab40613Stracker-user * 2. A custom action, do=usersettings, claimed in ACTION_ACT_PREPROCESS and 121ab40613Stracker-user * rendered in TPL_ACT_UNKNOWN. This is the documented way for a plugin to 131ab40613Stracker-user * own a do= value: preventing the preprocess default makes DokuWiki route 141ab40613Stracker-user * the action through dokuwiki\Action\Plugin, which fires TPL_ACT_UNKNOWN. 151ab40613Stracker-user * 161ab40613Stracker-user * 3. The settings page itself: a plain HTML form of every registered toggle, 171ab40613Stracker-user * with Post/Redirect/Get handling that saves through the helper. 181ab40613Stracker-user */ 191ab40613Stracker-user 201ab40613Stracker-user// must be run within DokuWiki 211ab40613Stracker-userif (!defined('DOKU_INC')) die(); 221ab40613Stracker-user 2349b74e0aStracker-useruse dokuwiki\Extension\ActionPlugin; 2449b74e0aStracker-useruse dokuwiki\Extension\EventHandler; 2549b74e0aStracker-useruse dokuwiki\Extension\Event; 2649b74e0aStracker-user 2749b74e0aStracker-userclass action_plugin_usersettings extends ActionPlugin 281ab40613Stracker-user{ 291ab40613Stracker-user /** the do= value this plugin owns */ 301ab40613Stracker-user const ACTION = 'usersettings'; 311ab40613Stracker-user 32*1a25f39dStracker-user /** GET parameter that carries the user's language to the (session-less) js.php request */ 33*1a25f39dStracker-user const JS_LANG_PARAM = 'uslang'; 34*1a25f39dStracker-user 35*1a25f39dStracker-user /** 36*1a25f39dStracker-user * The site-default language as it was *before* applyUserLang() overrode it 37*1a25f39dStracker-user * for this request, or null when no override happened. Captured so the 38*1a25f39dStracker-user * js.php URL builder can tell whether the user's choice actually differs 39*1a25f39dStracker-user * from the site default (by the time it runs, $conf['lang'] is already the 40*1a25f39dStracker-user * user's language). 41*1a25f39dStracker-user * 42*1a25f39dStracker-user * @var string|null 43*1a25f39dStracker-user */ 44*1a25f39dStracker-user protected $siteDefaultLang = null; 45*1a25f39dStracker-user 461ab40613Stracker-user /** 471ab40613Stracker-user * Register event handlers. 481ab40613Stracker-user */ 4949b74e0aStracker-user public function register(EventHandler $controller) 501ab40613Stracker-user { 511ab40613Stracker-user $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly'); 521ab40613Stracker-user $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess'); 531ab40613Stracker-user $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown'); 54cc98f4d1Stracker-user 55cc98f4d1Stracker-user // Register the built-in interface language toggle. 56cc98f4d1Stracker-user $controller->register_hook( 57cc98f4d1Stracker-user helper_plugin_usersettings::REGISTER_EVENT, 58cc98f4d1Stracker-user 'BEFORE', 59cc98f4d1Stracker-user $this, 60cc98f4d1Stracker-user 'registerLangToggle' 61cc98f4d1Stracker-user ); 62cc98f4d1Stracker-user 63cc98f4d1Stracker-user // Apply the user's language choice as early as possible so that all 64cc98f4d1Stracker-user // DokuWiki rendering — including TPL_ hooks further down the chain — 65cc98f4d1Stracker-user // uses the right language strings. ACTION_ACT_PREPROCESS fires before 66cc98f4d1Stracker-user // any output is produced and before template rendering begins. 67cc98f4d1Stracker-user $controller->register_hook( 68cc98f4d1Stracker-user 'ACTION_ACT_PREPROCESS', 69cc98f4d1Stracker-user 'BEFORE', 70cc98f4d1Stracker-user $this, 71cc98f4d1Stracker-user 'applyUserLang', 72cc98f4d1Stracker-user null, 73cc98f4d1Stracker-user // run at priority -10 so we fire before handlePreprocess (0) and 74cc98f4d1Stracker-user // before anything else that might read $conf['lang'] 75cc98f4d1Stracker-user -10 76cc98f4d1Stracker-user ); 7726676c97Stracker-user 78*1a25f39dStracker-user // js.php is its own request and runs with NOSESSION, so it has no 79*1a25f39dStracker-user // REMOTE_USER and ACTION_ACT_PREPROCESS never fires for it. We therefore 80*1a25f39dStracker-user // carry the user's language to js.php through the <script> URL: this hook 81*1a25f39dStracker-user // (fired during the normal, authenticated page request) appends 82*1a25f39dStracker-user // &uslang=<code> to the js.php src, which both signals the language to 83*1a25f39dStracker-user // js.php and makes the browser cache the bundle per language. 84*1a25f39dStracker-user $controller->register_hook( 85*1a25f39dStracker-user 'TPL_METAHEADER_OUTPUT', 86*1a25f39dStracker-user 'BEFORE', 87*1a25f39dStracker-user $this, 88*1a25f39dStracker-user 'appendUserLangToJsUrl' 89*1a25f39dStracker-user ); 90*1a25f39dStracker-user 91*1a25f39dStracker-user // Read the language back off the js.php URL (it survives NOSESSION) and 92*1a25f39dStracker-user // switch $conf['lang'] before js.php loads its strings. Without this the 93*1a25f39dStracker-user // JavaScript language bundle (LANG, LANG.plugins.*) always ships in the 94*1a25f39dStracker-user // SITE-default language. JS_SCRIPT_LIST is the one event js.php fires 95*1a25f39dStracker-user // before it builds its cache key and loads JS strings. 9626676c97Stracker-user $controller->register_hook( 9726676c97Stracker-user 'JS_SCRIPT_LIST', 9826676c97Stracker-user 'BEFORE', 9926676c97Stracker-user $this, 10026676c97Stracker-user 'applyUserLangToScripts' 10126676c97Stracker-user ); 1021ab40613Stracker-user } 1031ab40613Stracker-user 1041ab40613Stracker-user /** 1051ab40613Stracker-user * Load the storage/registration helper. 1061ab40613Stracker-user * 1071ab40613Stracker-user * @return helper_plugin_usersettings|null 1081ab40613Stracker-user */ 1091ab40613Stracker-user protected function getHelper() 1101ab40613Stracker-user { 1111ab40613Stracker-user /** @var helper_plugin_usersettings|null $helper */ 1121ab40613Stracker-user $helper = plugin_load('helper', 'usersettings'); 1131ab40613Stracker-user return $helper; 1141ab40613Stracker-user } 1151ab40613Stracker-user 1161ab40613Stracker-user // --------------------------------------------------------------------- 1171ab40613Stracker-user // 1. The user-menu item 1181ab40613Stracker-user // --------------------------------------------------------------------- 1191ab40613Stracker-user 1201ab40613Stracker-user /** 1211ab40613Stracker-user * Insert the "Preferences" item into the user menu, just before the 1221ab40613Stracker-user * "Update Profile" item. 1231ab40613Stracker-user * 12449b74e0aStracker-user * @param Event $event MENU_ITEMS_ASSEMBLY 1251ab40613Stracker-user * @param mixed $param 1261ab40613Stracker-user */ 12749b74e0aStracker-user public function handleMenuAssembly(Event $event, $param) 1281ab40613Stracker-user { 1291ab40613Stracker-user if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') { 1301ab40613Stracker-user return; 1311ab40613Stracker-user } 1321ab40613Stracker-user 1331ab40613Stracker-user try { 1341ab40613Stracker-user $item = new \dokuwiki\plugin\usersettings\MenuItem(); 1351ab40613Stracker-user } catch (\RuntimeException $e) { 1361ab40613Stracker-user // anonymous visitor, or the action is disabled — no menu item 1371ab40613Stracker-user return; 1381ab40613Stracker-user } 1391ab40613Stracker-user 1401ab40613Stracker-user if (!isset($event->data['items']) || !is_array($event->data['items'])) { 1411ab40613Stracker-user return; 1421ab40613Stracker-user } 1431ab40613Stracker-user $items =& $event->data['items']; 1441ab40613Stracker-user 1451ab40613Stracker-user // find the Profile item; default to appending if it is not present 1461ab40613Stracker-user $pos = count($items); 1471ab40613Stracker-user foreach ($items as $i => $existing) { 1481ab40613Stracker-user if ($existing instanceof \dokuwiki\Menu\Item\Profile) { 1491ab40613Stracker-user $pos = $i; 1501ab40613Stracker-user break; 1511ab40613Stracker-user } 1521ab40613Stracker-user } 1531ab40613Stracker-user array_splice($items, $pos, 0, [$item]); 1541ab40613Stracker-user } 1551ab40613Stracker-user 1561ab40613Stracker-user // --------------------------------------------------------------------- 1571ab40613Stracker-user // 2. Claiming the custom action + handling the save 1581ab40613Stracker-user // --------------------------------------------------------------------- 1591ab40613Stracker-user 1601ab40613Stracker-user /** 1611ab40613Stracker-user * Claim do=usersettings and, on a form submission, save and redirect. 1621ab40613Stracker-user * 16349b74e0aStracker-user * @param Event $event ACTION_ACT_PREPROCESS 1641ab40613Stracker-user * @param mixed $param 1651ab40613Stracker-user */ 16649b74e0aStracker-user public function handlePreprocess(Event $event, $param) 1671ab40613Stracker-user { 1681ab40613Stracker-user if ($event->data !== self::ACTION) { 1691ab40613Stracker-user return; 1701ab40613Stracker-user } 1711ab40613Stracker-user 1721ab40613Stracker-user // Preventing the default makes DokuWiki keep the action and route it 1731ab40613Stracker-user // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN. 1741ab40613Stracker-user $event->preventDefault(); 1751ab40613Stracker-user $event->stopPropagation(); 1761ab40613Stracker-user 1771ab40613Stracker-user global $INPUT, $ID; 1781ab40613Stracker-user 1791ab40613Stracker-user $user = $INPUT->server->str('REMOTE_USER'); 1801ab40613Stracker-user if ($user === '') { 1811ab40613Stracker-user return; // anonymous — the rendered page shows a login notice 1821ab40613Stracker-user } 1831ab40613Stracker-user 1841ab40613Stracker-user // not a save submission — nothing to do, the page will just render 1851ab40613Stracker-user if (!$INPUT->post->bool('usersettings_save')) { 1861ab40613Stracker-user return; 1871ab40613Stracker-user } 1881ab40613Stracker-user 1891ab40613Stracker-user // CSRF protection; checkSecurityToken() shows its own error on failure 1901ab40613Stracker-user if (!checkSecurityToken()) { 1911ab40613Stracker-user return; 1921ab40613Stracker-user } 1931ab40613Stracker-user 1941ab40613Stracker-user $ok = $this->saveSubmittedPreferences($user); 1951ab40613Stracker-user msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1); 1961ab40613Stracker-user 1971ab40613Stracker-user // Post/Redirect/Get: a refresh must not re-submit the form 1981ab40613Stracker-user send_redirect(wl($ID, ['do' => self::ACTION], true, '&')); 1991ab40613Stracker-user } 2001ab40613Stracker-user 2011ab40613Stracker-user /** 2021ab40613Stracker-user * Read the submitted toggle values for every registered toggle and store 2031ab40613Stracker-user * them for the given user. 2041ab40613Stracker-user * 2051ab40613Stracker-user * Kept separate from handlePreprocess() so it carries no redirect and can 2061ab40613Stracker-user * be exercised directly by tests. Checkboxes that are unchecked do not 2071ab40613Stracker-user * appear in the POST data, so every registered toggle is read explicitly 2081ab40613Stracker-user * rather than iterating whatever was submitted. 2091ab40613Stracker-user * 2101ab40613Stracker-user * @param string $user whose preferences are being written 2111ab40613Stracker-user * @param string|null $actor who is making the change; defaults to $user 2121ab40613Stracker-user * (the admin component passes the admin here) 2131ab40613Stracker-user * @return bool 2141ab40613Stracker-user */ 2151ab40613Stracker-user public function saveSubmittedPreferences($user, $actor = null) 2161ab40613Stracker-user { 2171ab40613Stracker-user global $INPUT; 2181ab40613Stracker-user 2191ab40613Stracker-user if ($actor === null) { 2201ab40613Stracker-user $actor = $user; 2211ab40613Stracker-user } 2221ab40613Stracker-user 2231ab40613Stracker-user $helper = $this->getHelper(); 2241ab40613Stracker-user if ($helper === null) { 2251ab40613Stracker-user return false; 2261ab40613Stracker-user } 2271ab40613Stracker-user 2281ab40613Stracker-user $values = []; 2291ab40613Stracker-user foreach ($helper->getRegisteredToggles() as $key => $def) { 2301ab40613Stracker-user if ($def['type'] === 'checkbox') { 2311ab40613Stracker-user $values[$key] = $INPUT->post->bool($key) ? 1 : 0; 2321ab40613Stracker-user } else { 2331ab40613Stracker-user $values[$key] = $INPUT->post->str($key); 2341ab40613Stracker-user } 2351ab40613Stracker-user } 2361ab40613Stracker-user 2371ab40613Stracker-user return $helper->setPreferences($values, $user, $actor); 2381ab40613Stracker-user } 2391ab40613Stracker-user 2401ab40613Stracker-user // --------------------------------------------------------------------- 2411ab40613Stracker-user // 3. Rendering the settings page 2421ab40613Stracker-user // --------------------------------------------------------------------- 2431ab40613Stracker-user 2441ab40613Stracker-user /** 2451ab40613Stracker-user * Render the settings page for do=usersettings. 2461ab40613Stracker-user * 24749b74e0aStracker-user * @param Event $event TPL_ACT_UNKNOWN 2481ab40613Stracker-user * @param mixed $param 2491ab40613Stracker-user */ 25049b74e0aStracker-user public function handleUnknown(Event $event, $param) 2511ab40613Stracker-user { 2521ab40613Stracker-user if ($event->data !== self::ACTION) { 2531ab40613Stracker-user return; 2541ab40613Stracker-user } 2551ab40613Stracker-user $event->preventDefault(); 2561ab40613Stracker-user $event->stopPropagation(); 2571ab40613Stracker-user 2581ab40613Stracker-user echo $this->renderSettingsPage(); 2591ab40613Stracker-user } 2601ab40613Stracker-user 2611ab40613Stracker-user /** 2621ab40613Stracker-user * Build the HTML of the settings page. 2631ab40613Stracker-user * 2641ab40613Stracker-user * @return string 2651ab40613Stracker-user */ 2661ab40613Stracker-user public function renderSettingsPage() 2671ab40613Stracker-user { 2681ab40613Stracker-user global $INPUT, $ID; 2691ab40613Stracker-user 2701ab40613Stracker-user $user = $INPUT->server->str('REMOTE_USER'); 2711ab40613Stracker-user 2721ab40613Stracker-user $html = '<div class="plugin_usersettings">'; 2731ab40613Stracker-user $html .= '<h1>' . hsc($this->getLang('heading')) . '</h1>'; 2741ab40613Stracker-user 2751ab40613Stracker-user if ($user === '') { 2761ab40613Stracker-user $html .= '<p>' . hsc($this->getLang('nologin')) . '</p>'; 2771ab40613Stracker-user return $html . '</div>'; 2781ab40613Stracker-user } 2791ab40613Stracker-user 2801ab40613Stracker-user $helper = $this->getHelper(); 2811ab40613Stracker-user $toggles = $helper ? $helper->getRegisteredToggles() : []; 2821ab40613Stracker-user 2831ab40613Stracker-user if (empty($toggles)) { 2841ab40613Stracker-user $html .= '<p>' . hsc($this->getLang('notoggles')) . '</p>'; 2851ab40613Stracker-user return $html . '</div>'; 2861ab40613Stracker-user } 2871ab40613Stracker-user 2881ab40613Stracker-user $html .= '<p class="us-intro">' . hsc($this->getLang('intro')) . '</p>'; 2891ab40613Stracker-user 2901ab40613Stracker-user $action = wl($ID, ['do' => self::ACTION], false, '&'); 2911ab40613Stracker-user $html .= '<form method="post" action="' . $action . '" class="us-form">'; 2921ab40613Stracker-user $html .= formSecurityToken(false); 2931ab40613Stracker-user 2941ab40613Stracker-user foreach ($toggles as $key => $def) { 2951ab40613Stracker-user $html .= $this->renderToggleRow($def, $helper->getPreference($key, $user)); 2961ab40613Stracker-user } 2971ab40613Stracker-user 2981ab40613Stracker-user $html .= '<div class="us-actions">'; 2991ab40613Stracker-user $html .= '<button type="submit" name="usersettings_save" value="1" class="button">' 3001ab40613Stracker-user . hsc($this->getLang('save')) . '</button>'; 3011ab40613Stracker-user $html .= '</div>'; 3021ab40613Stracker-user $html .= '</form>'; 3031ab40613Stracker-user 3041ab40613Stracker-user return $html . '</div>'; 3051ab40613Stracker-user } 3061ab40613Stracker-user 307cc98f4d1Stracker-user // --------------------------------------------------------------------- 308cc98f4d1Stracker-user // Built-in: interface language toggle 309cc98f4d1Stracker-user // --------------------------------------------------------------------- 310cc98f4d1Stracker-user 311cc98f4d1Stracker-user /** 312cc98f4d1Stracker-user * Contribute the "Interface language" select to the usersettings registry. 313cc98f4d1Stracker-user * 314cc98f4d1Stracker-user * The option list is built by scanning DOKU_INC/inc/lang/ for sub- 315cc98f4d1Stracker-user * directories that contain a lang.php file — the same source the 316cc98f4d1Stracker-user * Configuration Manager uses for its own language drop-down. The scan 317cc98f4d1Stracker-user * result is sorted alphabetically by language code; the site default is 318cc98f4d1Stracker-user * used as the toggle's default value so the toggle appears pre-selected 319cc98f4d1Stracker-user * correctly for users who have never changed it. 320cc98f4d1Stracker-user * 32149b74e0aStracker-user * @param Event $event PLUGIN_USERSETTINGS_REGISTER 322cc98f4d1Stracker-user * @param mixed $param 323cc98f4d1Stracker-user */ 32449b74e0aStracker-user public function registerLangToggle(Event $event, $param) 325cc98f4d1Stracker-user { 326cc98f4d1Stracker-user global $conf; 327cc98f4d1Stracker-user 328cc98f4d1Stracker-user $options = $this->getAvailableLanguages(); 329cc98f4d1Stracker-user if (empty($options)) { 330cc98f4d1Stracker-user return; // nothing to register if we cannot list languages 331cc98f4d1Stracker-user } 332cc98f4d1Stracker-user 333cc98f4d1Stracker-user $siteDefault = $conf['lang'] ?? 'en'; 334cc98f4d1Stracker-user if (!array_key_exists($siteDefault, $options)) { 335cc98f4d1Stracker-user $siteDefault = array_key_first($options); 336cc98f4d1Stracker-user } 337cc98f4d1Stracker-user 338cc98f4d1Stracker-user $event->data[] = [ 339cc98f4d1Stracker-user 'key' => 'lang', 340cc98f4d1Stracker-user 'label' => $this->getLang('lang_label'), 341cc98f4d1Stracker-user 'desc' => $this->getLang('lang_desc'), 342cc98f4d1Stracker-user 'type' => 'select', 343cc98f4d1Stracker-user 'options' => $options, 344cc98f4d1Stracker-user 'default' => $siteDefault, 345cc98f4d1Stracker-user 'plugin' => 'usersettings', 346cc98f4d1Stracker-user ]; 347cc98f4d1Stracker-user } 348cc98f4d1Stracker-user 349cc98f4d1Stracker-user /** 350cc98f4d1Stracker-user * Build the [code => display name] map of all installed DokuWiki interface 35149b74e0aStracker-user * languages by scanning inc/lang/. The display name is the language's own 35249b74e0aStracker-user * native name (endonym), falling back to the bare code for any language not 35349b74e0aStracker-user * in the built-in map. 354cc98f4d1Stracker-user * 35549b74e0aStracker-user * @return array [langCode => endonym] sorted by language code 356cc98f4d1Stracker-user */ 357cc98f4d1Stracker-user protected function getAvailableLanguages() 358cc98f4d1Stracker-user { 359cc98f4d1Stracker-user $pattern = DOKU_INC . 'inc/lang/*/lang.php'; 360cc98f4d1Stracker-user $files = glob($pattern); 361cc98f4d1Stracker-user if ($files === false || empty($files)) { 362cc98f4d1Stracker-user return []; 363cc98f4d1Stracker-user } 364cc98f4d1Stracker-user 365cc98f4d1Stracker-user $langs = []; 366cc98f4d1Stracker-user foreach ($files as $file) { 367cc98f4d1Stracker-user $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php" 368cc98f4d1Stracker-user if ($code === '' || $code === '.' || $code === '..') { 369cc98f4d1Stracker-user continue; 370cc98f4d1Stracker-user } 37149b74e0aStracker-user $langs[$code] = $this->languageName($code); 372cc98f4d1Stracker-user } 373cc98f4d1Stracker-user 374cc98f4d1Stracker-user ksort($langs, SORT_STRING); 375cc98f4d1Stracker-user return $langs; 376cc98f4d1Stracker-user } 377cc98f4d1Stracker-user 378cc98f4d1Stracker-user /** 37949b74e0aStracker-user * Return the native name (endonym) for a language code. 38049b74e0aStracker-user * Falls back to the bare code for languages not in the built-in map. 38149b74e0aStracker-user * 38249b74e0aStracker-user * @param string $code ISO language code as used by DokuWiki 38349b74e0aStracker-user * @return string 38449b74e0aStracker-user */ 38549b74e0aStracker-user protected function languageName($code) 38649b74e0aStracker-user { 38749b74e0aStracker-user $names = [ 38849b74e0aStracker-user 'af' => 'Afrikaans', 38949b74e0aStracker-user 'ar' => 'العربية', 39049b74e0aStracker-user 'az' => 'Azərbaycan', 39149b74e0aStracker-user 'be' => 'Беларуская', 39249b74e0aStracker-user 'bg' => 'Български', 39349b74e0aStracker-user 'bn' => 'বাংলা', 39449b74e0aStracker-user 'br' => 'Brezhoneg', 39549b74e0aStracker-user 'ca' => 'Català', 39649b74e0aStracker-user 'ca-valencia' => 'Català (Valencià)', 39749b74e0aStracker-user 'ckb' => 'کوردی سۆرانی', 39849b74e0aStracker-user 'cs' => 'Čeština', 39949b74e0aStracker-user 'cy' => 'Cymraeg', 40049b74e0aStracker-user 'da' => 'Dansk', 40149b74e0aStracker-user 'de' => 'Deutsch', 40249b74e0aStracker-user 'de-informal' => 'Deutsch (informell)', 40349b74e0aStracker-user 'el' => 'Ελληνικά', 40449b74e0aStracker-user 'en' => 'English', 40549b74e0aStracker-user 'eo' => 'Esperanto', 40649b74e0aStracker-user 'es' => 'Español', 40749b74e0aStracker-user 'et' => 'Eesti', 40849b74e0aStracker-user 'eu' => 'Euskara', 40949b74e0aStracker-user 'fa' => 'فارسی', 41049b74e0aStracker-user 'fi' => 'Suomi', 41149b74e0aStracker-user 'fo' => 'Føroyskt', 41249b74e0aStracker-user 'fr' => 'Français', 41349b74e0aStracker-user 'fy' => 'Frysk', 41449b74e0aStracker-user 'gl' => 'Galego', 41549b74e0aStracker-user 'he' => 'עברית', 41649b74e0aStracker-user 'hi' => 'हिन्दी', 41749b74e0aStracker-user 'hr' => 'Hrvatski', 41849b74e0aStracker-user 'hu' => 'Magyar', 41949b74e0aStracker-user 'hu-formal' => 'Magyar (magázó)', 42049b74e0aStracker-user 'hy' => 'Հայերեն', 42149b74e0aStracker-user 'ia' => 'Interlingua', 42249b74e0aStracker-user 'id' => 'Bahasa Indonesia', 42349b74e0aStracker-user 'id-ni' => 'Bahasa Indonesia (NTT)', 42449b74e0aStracker-user 'is' => 'Íslenska', 42549b74e0aStracker-user 'it' => 'Italiano', 42649b74e0aStracker-user 'ja' => '日本語', 42749b74e0aStracker-user 'ka' => 'ქართული', 42849b74e0aStracker-user 'kk' => 'Қазақша', 42949b74e0aStracker-user 'km' => 'ភាសាខ្មែរ', 43049b74e0aStracker-user 'kn' => 'ಕನ್ನಡ', 43149b74e0aStracker-user 'ko' => '한국어', 43249b74e0aStracker-user 'ku' => 'Kurdî', 43349b74e0aStracker-user 'la' => 'Latina', 43449b74e0aStracker-user 'lb' => 'Lëtzebuergesch', 43549b74e0aStracker-user 'lt' => 'Lietuvių', 43649b74e0aStracker-user 'lv' => 'Latviešu', 43749b74e0aStracker-user 'mg' => 'Malagasy', 43849b74e0aStracker-user 'mk' => 'Македонски', 43949b74e0aStracker-user 'ml' => 'മലയാളം', 44049b74e0aStracker-user 'mr' => 'मराठी', 44149b74e0aStracker-user 'ms' => 'Bahasa Melayu', 44249b74e0aStracker-user 'my' => 'မြန်မာ', 44349b74e0aStracker-user 'nan' => '閩南語', 44449b74e0aStracker-user 'nb' => 'Norsk bokmål', 44549b74e0aStracker-user 'ne' => 'नेपाली', 44649b74e0aStracker-user 'nl' => 'Nederlands', 44749b74e0aStracker-user 'nn' => 'Nynorsk', 44849b74e0aStracker-user 'no' => 'Norsk', 44949b74e0aStracker-user 'oc' => 'Occitan', 45049b74e0aStracker-user 'pl' => 'Polski', 45149b74e0aStracker-user 'pt' => 'Português', 45249b74e0aStracker-user 'pt-br' => 'Português brasileiro', 45349b74e0aStracker-user 'ro' => 'Română', 45449b74e0aStracker-user 'ru' => 'Русский', 45549b74e0aStracker-user 'si' => 'සිංහල', 45649b74e0aStracker-user 'sk' => 'Slovenčina', 45749b74e0aStracker-user 'sl' => 'Slovenščina', 45849b74e0aStracker-user 'sq' => 'Shqip', 45949b74e0aStracker-user 'sr' => 'Српски', 46049b74e0aStracker-user 'sv' => 'Svenska', 46149b74e0aStracker-user 'sw' => 'Kiswahili', 46249b74e0aStracker-user 'ta' => 'தமிழ்', 46349b74e0aStracker-user 'te' => 'తెలుగు', 46449b74e0aStracker-user 'th' => 'ภาษาไทย', 46549b74e0aStracker-user 'tr' => 'Türkçe', 46649b74e0aStracker-user 'uk' => 'Українська', 46749b74e0aStracker-user 'ur' => 'اردو', 46849b74e0aStracker-user 'uz' => 'Oʻzbekcha', 46949b74e0aStracker-user 'vi' => 'Tiếng Việt', 47049b74e0aStracker-user 'zh' => '中文 (简体)', 47149b74e0aStracker-user 'zh-tw' => '中文 (繁體)', 47249b74e0aStracker-user ]; 47349b74e0aStracker-user 47449b74e0aStracker-user return $names[$code] ?? $code; 47549b74e0aStracker-user } 47649b74e0aStracker-user 47749b74e0aStracker-user /** 478cc98f4d1Stracker-user * Apply the logged-in user's preferred interface language, overriding the 479cc98f4d1Stracker-user * site-wide $conf['lang'] before any rendering takes place. 480cc98f4d1Stracker-user * 481cc98f4d1Stracker-user * DokuWiki loads language strings lazily (via getLang() / $lang global 482cc98f4d1Stracker-user * reloads triggered by calls to init_lang()), so changing $conf['lang'] 483cc98f4d1Stracker-user * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient 48449b74e0aStracker-user * to affect all subsequent output, including this plugin's own chrome. 48549b74e0aStracker-user * 48649b74e0aStracker-user * We read the stored record directly via getRecord() rather than routing 48749b74e0aStracker-user * through getPreference(). getPreference() fires PLUGIN_USERSETTINGS_REGISTER 48849b74e0aStracker-user * (and the full inc/lang/ glob) on every logged-in request before the 48949b74e0aStracker-user * language is known. Reading the raw record avoids that overhead and, 49049b74e0aStracker-user * crucially, means the toggle registry is built *after* $conf['lang'] has 49149b74e0aStracker-user * been updated — so toggle labels resolve in the user's chosen language. 492cc98f4d1Stracker-user * 493cc98f4d1Stracker-user * No-op for anonymous visitors or when the user has not chosen a language 494cc98f4d1Stracker-user * that differs from the site default. 495cc98f4d1Stracker-user * 49649b74e0aStracker-user * @param Event $event ACTION_ACT_PREPROCESS 497cc98f4d1Stracker-user * @param mixed $param 498cc98f4d1Stracker-user */ 49949b74e0aStracker-user public function applyUserLang(Event $event, $param) 500cc98f4d1Stracker-user { 50126676c97Stracker-user global $conf; 502cc98f4d1Stracker-user 50326676c97Stracker-user $preferred = $this->resolvePreferredLang(); 50426676c97Stracker-user if ($preferred === null) { 50526676c97Stracker-user return; // anonymous, no/invalid preference, or already correct 506cc98f4d1Stracker-user } 507cc98f4d1Stracker-user 508*1a25f39dStracker-user // Remember the real site default before we override it, so the js.php 509*1a25f39dStracker-user // URL builder (which runs after this, when $conf['lang'] is already the 510*1a25f39dStracker-user // user's choice) can still tell the two apart. 511*1a25f39dStracker-user $this->siteDefaultLang = $conf['lang']; 512*1a25f39dStracker-user 513cc98f4d1Stracker-user $conf['lang'] = $preferred; 514cc98f4d1Stracker-user 515cc98f4d1Stracker-user // Re-initialise the global $lang array so immediately-following 516cc98f4d1Stracker-user // getLang() calls within this request pick up the new language. 517cc98f4d1Stracker-user init_lang($preferred); 518cc98f4d1Stracker-user } 519cc98f4d1Stracker-user 52026676c97Stracker-user /** 521*1a25f39dStracker-user * Append the user's interface language to the lib/exe/js.php <script> URL. 522*1a25f39dStracker-user * 523*1a25f39dStracker-user * js.php runs with NOSESSION (no REMOTE_USER), so it cannot look the user's 524*1a25f39dStracker-user * preference up itself — we pass it on the URL instead. The query parameter 525*1a25f39dStracker-user * also makes the browser treat each language as a distinct resource, so the 526*1a25f39dStracker-user * cached English bundle is not reused after a language switch. 527*1a25f39dStracker-user * 528*1a25f39dStracker-user * Runs in the normal page request, where the session (and the language 529*1a25f39dStracker-user * override from applyUserLang) is available. No-op when the user has no 530*1a25f39dStracker-user * preference, or it equals the site default. 531*1a25f39dStracker-user * 532*1a25f39dStracker-user * @param Event $event TPL_METAHEADER_OUTPUT ($event->data is the head array, by ref) 533*1a25f39dStracker-user * @param mixed $param 534*1a25f39dStracker-user */ 535*1a25f39dStracker-user public function appendUserLangToJsUrl(Event $event, $param) 536*1a25f39dStracker-user { 537*1a25f39dStracker-user global $conf; 538*1a25f39dStracker-user 539*1a25f39dStracker-user $preferred = $this->getValidatedStoredLang(); 540*1a25f39dStracker-user if ($preferred === null) { 541*1a25f39dStracker-user return; 542*1a25f39dStracker-user } 543*1a25f39dStracker-user 544*1a25f39dStracker-user // $conf['lang'] may already be the user's language (applyUserLang ran), 545*1a25f39dStracker-user // so compare against the captured site default when we have it. 546*1a25f39dStracker-user $default = $this->siteDefaultLang ?? $conf['lang']; 547*1a25f39dStracker-user if ($preferred === $default) { 548*1a25f39dStracker-user return; 549*1a25f39dStracker-user } 550*1a25f39dStracker-user 551*1a25f39dStracker-user if (empty($event->data['script']) || !is_array($event->data['script'])) { 552*1a25f39dStracker-user return; 553*1a25f39dStracker-user } 554*1a25f39dStracker-user 555*1a25f39dStracker-user foreach ($event->data['script'] as $i => $script) { 556*1a25f39dStracker-user if (!is_array($script) || empty($script['src'])) { 557*1a25f39dStracker-user continue; 558*1a25f39dStracker-user } 559*1a25f39dStracker-user if (strpos($script['src'], 'lib/exe/js.php') === false) { 560*1a25f39dStracker-user continue; 561*1a25f39dStracker-user } 562*1a25f39dStracker-user $event->data['script'][$i]['src'] .= 563*1a25f39dStracker-user '&' . self::JS_LANG_PARAM . '=' . rawurlencode($preferred); 564*1a25f39dStracker-user } 565*1a25f39dStracker-user } 566*1a25f39dStracker-user 567*1a25f39dStracker-user /** 56826676c97Stracker-user * Apply the user's interface language to the on-the-fly JavaScript bundle 56926676c97Stracker-user * (lib/exe/js.php). 57026676c97Stracker-user * 571*1a25f39dStracker-user * js.php is its own request, runs with NOSESSION (no REMOTE_USER), and never 572*1a25f39dStracker-user * fires ACTION_ACT_PREPROCESS, so applyUserLang() does not reach it and we 573*1a25f39dStracker-user * cannot look the user up here. The language is instead read from the 574*1a25f39dStracker-user * &uslang= URL parameter that appendUserLangToJsUrl() put on the <script> 575*1a25f39dStracker-user * src; it survives NOSESSION because it travels in the URL, not the session. 576*1a25f39dStracker-user * 577*1a25f39dStracker-user * Two things must happen here, both before js.php proceeds: 57826676c97Stracker-user * 57926676c97Stracker-user * 1. Switch $conf['lang'] (+ init_lang) so js_pluginstrings() and friends 58026676c97Stracker-user * read the user's language. 58126676c97Stracker-user * 2. Repoint the language-specific datepicker entry already present in 58226676c97Stracker-user * the file list. js.php keys its output cache on 58326676c97Stracker-user * md5(serialize($files)); that datepicker path is the only 58426676c97Stracker-user * language-dependent member, so without rewriting it two users with 58526676c97Stracker-user * different languages would collide on a single cached bundle. js.php 58626676c97Stracker-user * skips non-existent files, so this is safe even for a language that 58726676c97Stracker-user * ships no datepicker translation. 58826676c97Stracker-user * 589*1a25f39dStracker-user * The parameter is user-controllable, so it is validated to a real 590*1a25f39dStracker-user * inc/lang/ directory (lowercase [a-z0-9-]) before use. 591*1a25f39dStracker-user * 59226676c97Stracker-user * @param Event $event JS_SCRIPT_LIST ($event->data is the file list, by ref) 59326676c97Stracker-user * @param mixed $param 59426676c97Stracker-user */ 59526676c97Stracker-user public function applyUserLangToScripts(Event $event, $param) 59626676c97Stracker-user { 597*1a25f39dStracker-user global $conf, $INPUT; 59826676c97Stracker-user 599*1a25f39dStracker-user $preferred = $INPUT->str(self::JS_LANG_PARAM); 600*1a25f39dStracker-user if (!$this->isValidLangCode($preferred) || $preferred === $conf['lang']) { 60126676c97Stracker-user return; 60226676c97Stracker-user } 60326676c97Stracker-user 60426676c97Stracker-user $old = $conf['lang']; 60526676c97Stracker-user 60626676c97Stracker-user if (is_array($event->data)) { 60726676c97Stracker-user $needle = 'inc/lang/' . $old . '/jquery.ui.datepicker.js'; 60826676c97Stracker-user $replacement = 'inc/lang/' . $preferred . '/jquery.ui.datepicker.js'; 60926676c97Stracker-user foreach ($event->data as $i => $file) { 61026676c97Stracker-user if (is_string($file) && strpos($file, $needle) !== false) { 61126676c97Stracker-user $event->data[$i] = str_replace($needle, $replacement, $file); 61226676c97Stracker-user } 61326676c97Stracker-user } 61426676c97Stracker-user } 61526676c97Stracker-user 61626676c97Stracker-user $conf['lang'] = $preferred; 61726676c97Stracker-user init_lang($preferred); 61826676c97Stracker-user } 61926676c97Stracker-user 62026676c97Stracker-user /** 62126676c97Stracker-user * Resolve the logged-in user's stored, validated interface-language 62226676c97Stracker-user * preference, or null when there is none to apply. 62326676c97Stracker-user * 62426676c97Stracker-user * Returns null for anonymous visitors, when no preference is stored, when 62526676c97Stracker-user * the stored value is malformed or names a missing inc/lang/ directory, or 62626676c97Stracker-user * when it already matches the active $conf['lang']. Reads the raw stored 62726676c97Stracker-user * record via getRecord() so it does NOT fire PLUGIN_USERSETTINGS_REGISTER 62826676c97Stracker-user * (which would glob inc/lang/ on every request). 62926676c97Stracker-user * 63026676c97Stracker-user * @return string|null validated language code, or null for "leave as-is" 63126676c97Stracker-user */ 63226676c97Stracker-user protected function resolvePreferredLang() 63326676c97Stracker-user { 634*1a25f39dStracker-user global $conf; 635*1a25f39dStracker-user 636*1a25f39dStracker-user $preferred = $this->getValidatedStoredLang(); 637*1a25f39dStracker-user if ($preferred === null || $preferred === $conf['lang']) { 638*1a25f39dStracker-user return null; // no preference, invalid, or already correct 639*1a25f39dStracker-user } 640*1a25f39dStracker-user 641*1a25f39dStracker-user return $preferred; 642*1a25f39dStracker-user } 643*1a25f39dStracker-user 644*1a25f39dStracker-user /** 645*1a25f39dStracker-user * The logged-in user's stored, validated interface-language preference, 646*1a25f39dStracker-user * irrespective of the currently active $conf['lang']. 647*1a25f39dStracker-user * 648*1a25f39dStracker-user * Unlike resolvePreferredLang() this does NOT short-circuit when the stored 649*1a25f39dStracker-user * value already equals $conf['lang'] — the js.php URL builder needs the raw 650*1a25f39dStracker-user * preference because applyUserLang() may have already switched $conf['lang'] 651*1a25f39dStracker-user * to it. Reads the raw stored record via getRecord() so it does NOT fire 652*1a25f39dStracker-user * PLUGIN_USERSETTINGS_REGISTER (which would glob inc/lang/ on every request). 653*1a25f39dStracker-user * 654*1a25f39dStracker-user * @return string|null validated language code, or null when there is none 655*1a25f39dStracker-user */ 656*1a25f39dStracker-user protected function getValidatedStoredLang() 657*1a25f39dStracker-user { 658*1a25f39dStracker-user global $INPUT; 65926676c97Stracker-user 66026676c97Stracker-user $user = $INPUT->server->str('REMOTE_USER'); 66126676c97Stracker-user if ($user === '') { 66226676c97Stracker-user return null; // anonymous — use the site default 66326676c97Stracker-user } 66426676c97Stracker-user 66526676c97Stracker-user $helper = $this->getHelper(); 66626676c97Stracker-user if ($helper === null) { 66726676c97Stracker-user return null; 66826676c97Stracker-user } 66926676c97Stracker-user 67026676c97Stracker-user // Read the raw stored record — does not fire PLUGIN_USERSETTINGS_REGISTER. 67126676c97Stracker-user $record = $helper->getRecord('lang', $user); 67226676c97Stracker-user $preferred = (is_array($record) && isset($record['value'])) ? (string) $record['value'] : null; 67326676c97Stracker-user 674*1a25f39dStracker-user return $this->isValidLangCode($preferred) ? $preferred : null; 67526676c97Stracker-user } 67626676c97Stracker-user 677*1a25f39dStracker-user /** 678*1a25f39dStracker-user * Whether a string is a usable interface-language code: lowercase 679*1a25f39dStracker-user * [a-z0-9-] (defence-in-depth against path traversal) naming an existing 680*1a25f39dStracker-user * inc/lang/ directory (so a stale or bogus code never breaks the page). 681*1a25f39dStracker-user * 682*1a25f39dStracker-user * @param string|null $code 683*1a25f39dStracker-user * @return bool 684*1a25f39dStracker-user */ 685*1a25f39dStracker-user protected function isValidLangCode($code) 686*1a25f39dStracker-user { 687*1a25f39dStracker-user if ($code === null || $code === '') { 688*1a25f39dStracker-user return false; 68926676c97Stracker-user } 690*1a25f39dStracker-user if (!preg_match('/^[a-z0-9-]+$/', $code)) { 691*1a25f39dStracker-user return false; 69226676c97Stracker-user } 693*1a25f39dStracker-user return is_dir(DOKU_INC . 'inc/lang/' . $code); 69426676c97Stracker-user } 69526676c97Stracker-user 696cc98f4d1Stracker-user // --------------------------------------------------------------------- 697cc98f4d1Stracker-user // Form rendering (shared between action and admin) 698cc98f4d1Stracker-user // --------------------------------------------------------------------- 699cc98f4d1Stracker-user 7001ab40613Stracker-user /** 7011ab40613Stracker-user * Render one toggle as a form row. Public so the admin component can 7021ab40613Stracker-user * reuse it for its per-user edit form. 7031ab40613Stracker-user * 7041ab40613Stracker-user * @param array $def a normalised toggle definition 7051ab40613Stracker-user * @param mixed $value the user's effective value for this toggle 7061ab40613Stracker-user * @return string 7071ab40613Stracker-user */ 7081ab40613Stracker-user public function renderToggleRow(array $def, $value) 7091ab40613Stracker-user { 7101ab40613Stracker-user $key = hsc($def['key']); 7111ab40613Stracker-user 7121ab40613Stracker-user if ($def['type'] === 'select') { 7131ab40613Stracker-user $id = 'us__' . $key; 7141ab40613Stracker-user $html = '<div class="us-row us-row-select">'; 7151ab40613Stracker-user $html .= '<label class="us-label" for="' . $id . '">'; 7161ab40613Stracker-user $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 7171ab40613Stracker-user $html .= '<select name="' . $key . '" id="' . $id . '">'; 7181ab40613Stracker-user foreach ($def['options'] as $optValue => $optLabel) { 7191ab40613Stracker-user $selected = ((string) $optValue === (string) $value) ? ' selected="selected"' : ''; 7201ab40613Stracker-user $html .= '<option value="' . hsc((string) $optValue) . '"' . $selected . '>' 7211ab40613Stracker-user . hsc((string) $optLabel) . '</option>'; 7221ab40613Stracker-user } 7231ab40613Stracker-user $html .= '</select>'; 7241ab40613Stracker-user $html .= '</label>'; 7251ab40613Stracker-user } else { 7261ab40613Stracker-user $checked = empty($value) ? '' : ' checked="checked"'; 7271ab40613Stracker-user $html = '<div class="us-row us-row-checkbox">'; 7281ab40613Stracker-user $html .= '<label class="us-label">'; 7291ab40613Stracker-user $html .= '<input type="checkbox" name="' . $key . '" value="1"' . $checked . ' />'; 7301ab40613Stracker-user $html .= '<span class="us-name">' . hsc($def['label']) . '</span>'; 7311ab40613Stracker-user $html .= '</label>'; 7321ab40613Stracker-user } 7331ab40613Stracker-user 7341ab40613Stracker-user if ($def['desc'] !== '') { 7351ab40613Stracker-user $html .= '<div class="us-desc">' . hsc($def['desc']) . '</div>'; 7361ab40613Stracker-user } 7371ab40613Stracker-user 7381ab40613Stracker-user return $html . '</div>'; 7391ab40613Stracker-user } 7401ab40613Stracker-user} 741