register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'handleMenuAssembly'); $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handlePreprocess'); $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handleUnknown'); // Register the built-in interface language toggle. $controller->register_hook( helper_plugin_usersettings::REGISTER_EVENT, 'BEFORE', $this, 'registerLangToggle' ); // Apply the user's language choice as early as possible so that all // DokuWiki rendering — including TPL_ hooks further down the chain — // uses the right language strings. ACTION_ACT_PREPROCESS fires before // any output is produced and before template rendering begins. $controller->register_hook( 'ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'applyUserLang', null, // run at priority -10 so we fire before handlePreprocess (0) and // before anything else that might read $conf['lang'] -10 ); // lib/exe/js.php is its own request and never fires // ACTION_ACT_PREPROCESS, so applyUserLang() cannot reach it. Without // this hook the JavaScript language bundle (LANG, LANG.plugins.*) // always ships in the SITE-default language, ignoring the user's // choice. JS_SCRIPT_LIST is the one event js.php fires before it builds // its cache key and loads JS strings. $controller->register_hook( 'JS_SCRIPT_LIST', 'BEFORE', $this, 'applyUserLangToScripts' ); } /** * Load the storage/registration helper. * * @return helper_plugin_usersettings|null */ protected function getHelper() { /** @var helper_plugin_usersettings|null $helper */ $helper = plugin_load('helper', 'usersettings'); return $helper; } // --------------------------------------------------------------------- // 1. The user-menu item // --------------------------------------------------------------------- /** * Insert the "Preferences" item into the user menu, just before the * "Update Profile" item. * * @param Event $event MENU_ITEMS_ASSEMBLY * @param mixed $param */ public function handleMenuAssembly(Event $event, $param) { if (!is_array($event->data) || ($event->data['view'] ?? '') !== 'user') { return; } try { $item = new \dokuwiki\plugin\usersettings\MenuItem(); } catch (\RuntimeException $e) { // anonymous visitor, or the action is disabled — no menu item return; } if (!isset($event->data['items']) || !is_array($event->data['items'])) { return; } $items =& $event->data['items']; // find the Profile item; default to appending if it is not present $pos = count($items); foreach ($items as $i => $existing) { if ($existing instanceof \dokuwiki\Menu\Item\Profile) { $pos = $i; break; } } array_splice($items, $pos, 0, [$item]); } // --------------------------------------------------------------------- // 2. Claiming the custom action + handling the save // --------------------------------------------------------------------- /** * Claim do=usersettings and, on a form submission, save and redirect. * * @param Event $event ACTION_ACT_PREPROCESS * @param mixed $param */ public function handlePreprocess(Event $event, $param) { if ($event->data !== self::ACTION) { return; } // Preventing the default makes DokuWiki keep the action and route it // through dokuwiki\Action\Plugin, which will fire TPL_ACT_UNKNOWN. $event->preventDefault(); $event->stopPropagation(); global $INPUT, $ID; $user = $INPUT->server->str('REMOTE_USER'); if ($user === '') { return; // anonymous — the rendered page shows a login notice } // not a save submission — nothing to do, the page will just render if (!$INPUT->post->bool('usersettings_save')) { return; } // CSRF protection; checkSecurityToken() shows its own error on failure if (!checkSecurityToken()) { return; } $ok = $this->saveSubmittedPreferences($user); msg($this->getLang($ok ? 'saved' : 'savefail'), $ok ? 1 : -1); // Post/Redirect/Get: a refresh must not re-submit the form send_redirect(wl($ID, ['do' => self::ACTION], true, '&')); } /** * Read the submitted toggle values for every registered toggle and store * them for the given user. * * Kept separate from handlePreprocess() so it carries no redirect and can * be exercised directly by tests. Checkboxes that are unchecked do not * appear in the POST data, so every registered toggle is read explicitly * rather than iterating whatever was submitted. * * @param string $user whose preferences are being written * @param string|null $actor who is making the change; defaults to $user * (the admin component passes the admin here) * @return bool */ public function saveSubmittedPreferences($user, $actor = null) { global $INPUT; if ($actor === null) { $actor = $user; } $helper = $this->getHelper(); if ($helper === null) { return false; } $values = []; foreach ($helper->getRegisteredToggles() as $key => $def) { if ($def['type'] === 'checkbox') { $values[$key] = $INPUT->post->bool($key) ? 1 : 0; } else { $values[$key] = $INPUT->post->str($key); } } return $helper->setPreferences($values, $user, $actor); } // --------------------------------------------------------------------- // 3. Rendering the settings page // --------------------------------------------------------------------- /** * Render the settings page for do=usersettings. * * @param Event $event TPL_ACT_UNKNOWN * @param mixed $param */ public function handleUnknown(Event $event, $param) { if ($event->data !== self::ACTION) { return; } $event->preventDefault(); $event->stopPropagation(); echo $this->renderSettingsPage(); } /** * Build the HTML of the settings page. * * @return string */ public function renderSettingsPage() { global $INPUT, $ID; $user = $INPUT->server->str('REMOTE_USER'); $html = '
' . hsc($this->getLang('nologin')) . '
'; return $html . '' . hsc($this->getLang('notoggles')) . '
'; return $html . ''; } $html .= '' . hsc($this->getLang('intro')) . '
'; $action = wl($ID, ['do' => self::ACTION], false, '&'); $html .= ''; return $html . ''; } // --------------------------------------------------------------------- // Built-in: interface language toggle // --------------------------------------------------------------------- /** * Contribute the "Interface language" select to the usersettings registry. * * The option list is built by scanning DOKU_INC/inc/lang/ for sub- * directories that contain a lang.php file — the same source the * Configuration Manager uses for its own language drop-down. The scan * result is sorted alphabetically by language code; the site default is * used as the toggle's default value so the toggle appears pre-selected * correctly for users who have never changed it. * * @param Event $event PLUGIN_USERSETTINGS_REGISTER * @param mixed $param */ public function registerLangToggle(Event $event, $param) { global $conf; $options = $this->getAvailableLanguages(); if (empty($options)) { return; // nothing to register if we cannot list languages } $siteDefault = $conf['lang'] ?? 'en'; if (!array_key_exists($siteDefault, $options)) { $siteDefault = array_key_first($options); } $event->data[] = [ 'key' => 'lang', 'label' => $this->getLang('lang_label'), 'desc' => $this->getLang('lang_desc'), 'type' => 'select', 'options' => $options, 'default' => $siteDefault, 'plugin' => 'usersettings', ]; } /** * Build the [code => display name] map of all installed DokuWiki interface * languages by scanning inc/lang/. The display name is the language's own * native name (endonym), falling back to the bare code for any language not * in the built-in map. * * @return array [langCode => endonym] sorted by language code */ protected function getAvailableLanguages() { $pattern = DOKU_INC . 'inc/lang/*/lang.php'; $files = glob($pattern); if ($files === false || empty($files)) { return []; } $langs = []; foreach ($files as $file) { $code = basename(dirname($file)); // e.g. "de" from ".../inc/lang/de/lang.php" if ($code === '' || $code === '.' || $code === '..') { continue; } $langs[$code] = $this->languageName($code); } ksort($langs, SORT_STRING); return $langs; } /** * Return the native name (endonym) for a language code. * Falls back to the bare code for languages not in the built-in map. * * @param string $code ISO language code as used by DokuWiki * @return string */ protected function languageName($code) { $names = [ 'af' => 'Afrikaans', 'ar' => 'العربية', 'az' => 'Azərbaycan', 'be' => 'Беларуская', 'bg' => 'Български', 'bn' => 'বাংলা', 'br' => 'Brezhoneg', 'ca' => 'Català', 'ca-valencia' => 'Català (Valencià)', 'ckb' => 'کوردی سۆرانی', 'cs' => 'Čeština', 'cy' => 'Cymraeg', 'da' => 'Dansk', 'de' => 'Deutsch', 'de-informal' => 'Deutsch (informell)', 'el' => 'Ελληνικά', 'en' => 'English', 'eo' => 'Esperanto', 'es' => 'Español', 'et' => 'Eesti', 'eu' => 'Euskara', 'fa' => 'فارسی', 'fi' => 'Suomi', 'fo' => 'Føroyskt', 'fr' => 'Français', 'fy' => 'Frysk', 'gl' => 'Galego', 'he' => 'עברית', 'hi' => 'हिन्दी', 'hr' => 'Hrvatski', 'hu' => 'Magyar', 'hu-formal' => 'Magyar (magázó)', 'hy' => 'Հայերեն', 'ia' => 'Interlingua', 'id' => 'Bahasa Indonesia', 'id-ni' => 'Bahasa Indonesia (NTT)', 'is' => 'Íslenska', 'it' => 'Italiano', 'ja' => '日本語', 'ka' => 'ქართული', 'kk' => 'Қазақша', 'km' => 'ភាសាខ្មែរ', 'kn' => 'ಕನ್ನಡ', 'ko' => '한국어', 'ku' => 'Kurdî', 'la' => 'Latina', 'lb' => 'Lëtzebuergesch', 'lt' => 'Lietuvių', 'lv' => 'Latviešu', 'mg' => 'Malagasy', 'mk' => 'Македонски', 'ml' => 'മലയാളം', 'mr' => 'मराठी', 'ms' => 'Bahasa Melayu', 'my' => 'မြန်မာ', 'nan' => '閩南語', 'nb' => 'Norsk bokmål', 'ne' => 'नेपाली', 'nl' => 'Nederlands', 'nn' => 'Nynorsk', 'no' => 'Norsk', 'oc' => 'Occitan', 'pl' => 'Polski', 'pt' => 'Português', 'pt-br' => 'Português brasileiro', 'ro' => 'Română', 'ru' => 'Русский', 'si' => 'සිංහල', 'sk' => 'Slovenčina', 'sl' => 'Slovenščina', 'sq' => 'Shqip', 'sr' => 'Српски', 'sv' => 'Svenska', 'sw' => 'Kiswahili', 'ta' => 'தமிழ்', 'te' => 'తెలుగు', 'th' => 'ภาษาไทย', 'tr' => 'Türkçe', 'uk' => 'Українська', 'ur' => 'اردو', 'uz' => 'Oʻzbekcha', 'vi' => 'Tiếng Việt', 'zh' => '中文 (简体)', 'zh-tw' => '中文 (繁體)', ]; return $names[$code] ?? $code; } /** * Apply the logged-in user's preferred interface language, overriding the * site-wide $conf['lang'] before any rendering takes place. * * DokuWiki loads language strings lazily (via getLang() / $lang global * reloads triggered by calls to init_lang()), so changing $conf['lang'] * here — in the earliest ACTION_ACT_PREPROCESS handler — is sufficient * to affect all subsequent output, including this plugin's own chrome. * * We read the stored record directly via getRecord() rather than routing * through getPreference(). getPreference() fires PLUGIN_USERSETTINGS_REGISTER * (and the full inc/lang/ glob) on every logged-in request before the * language is known. Reading the raw record avoids that overhead and, * crucially, means the toggle registry is built *after* $conf['lang'] has * been updated — so toggle labels resolve in the user's chosen language. * * No-op for anonymous visitors or when the user has not chosen a language * that differs from the site default. * * @param Event $event ACTION_ACT_PREPROCESS * @param mixed $param */ public function applyUserLang(Event $event, $param) { global $conf; $preferred = $this->resolvePreferredLang(); if ($preferred === null) { return; // anonymous, no/invalid preference, or already correct } $conf['lang'] = $preferred; // Re-initialise the global $lang array so immediately-following // getLang() calls within this request pick up the new language. init_lang($preferred); } /** * Apply the user's interface language to the on-the-fly JavaScript bundle * (lib/exe/js.php). * * js.php is its own request and never fires ACTION_ACT_PREPROCESS, so * applyUserLang() does not reach it; without this, LANG.plugins.* and the * rest of the JS strings always ship in the SITE-default language. Two * things must happen here, both before js.php proceeds: * * 1. Switch $conf['lang'] (+ init_lang) so js_pluginstrings() and friends * read the user's language. * 2. Repoint the language-specific datepicker entry already present in * the file list. js.php keys its output cache on * md5(serialize($files)); that datepicker path is the only * language-dependent member, so without rewriting it two users with * different languages would collide on a single cached bundle. js.php * skips non-existent files, so this is safe even for a language that * ships no datepicker translation. * * @param Event $event JS_SCRIPT_LIST ($event->data is the file list, by ref) * @param mixed $param */ public function applyUserLangToScripts(Event $event, $param) { global $conf; $preferred = $this->resolvePreferredLang(); if ($preferred === null) { return; } $old = $conf['lang']; if (is_array($event->data)) { $needle = 'inc/lang/' . $old . '/jquery.ui.datepicker.js'; $replacement = 'inc/lang/' . $preferred . '/jquery.ui.datepicker.js'; foreach ($event->data as $i => $file) { if (is_string($file) && strpos($file, $needle) !== false) { $event->data[$i] = str_replace($needle, $replacement, $file); } } } $conf['lang'] = $preferred; init_lang($preferred); } /** * Resolve the logged-in user's stored, validated interface-language * preference, or null when there is none to apply. * * Returns null for anonymous visitors, when no preference is stored, when * the stored value is malformed or names a missing inc/lang/ directory, or * when it already matches the active $conf['lang']. Reads the raw stored * record via getRecord() so it does NOT fire PLUGIN_USERSETTINGS_REGISTER * (which would glob inc/lang/ on every request). * * @return string|null validated language code, or null for "leave as-is" */ protected function resolvePreferredLang() { global $conf, $INPUT; $user = $INPUT->server->str('REMOTE_USER'); if ($user === '') { return null; // anonymous — use the site default } $helper = $this->getHelper(); if ($helper === null) { return null; } // Read the raw stored record — does not fire PLUGIN_USERSETTINGS_REGISTER. $record = $helper->getRecord('lang', $user); $preferred = (is_array($record) && isset($record['value'])) ? (string) $record['value'] : null; if ($preferred === null || $preferred === '' || $preferred === $conf['lang']) { return null; // no preference stored or already correct } // Defence-in-depth: language codes are lowercase [a-z0-9-] only. if (!preg_match('/^[a-z0-9-]+$/', $preferred)) { return null; } // Validate: only apply if the directory actually exists to avoid a // broken page when someone stores a stale language code. if (!is_dir(DOKU_INC . 'inc/lang/' . $preferred)) { return null; } return $preferred; } // --------------------------------------------------------------------- // Form rendering (shared between action and admin) // --------------------------------------------------------------------- /** * Render one toggle as a form row. Public so the admin component can * reuse it for its per-user edit form. * * @param array $def a normalised toggle definition * @param mixed $value the user's effective value for this toggle * @return string */ public function renderToggleRow(array $def, $value) { $key = hsc($def['key']); if ($def['type'] === 'select') { $id = 'us__' . $key; $html = '