xref: /plugin/usersettings/action.php (revision 1a25f39dd1d18f916e99909728ff71c0ba821969)
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, '&amp;');
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