From 9158a66bff8fa50640eef7ac8830d47b7fb72c02 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 2 Sep 2022 19:19:01 +0100
Subject: [PATCH] Updated & improved language locale handling

Extracted much of the language and locale work to a seperate, focused class.
Updated php set_locale usage to prioritise UTF8 usage.
Added locale options for windows.
Clarified what's a locale and a bookstack language string.

For #3590 and maybe #3650
---
 app/Http/Middleware/Localization.php | 121 +++----------------------
 app/Util/LanguageManager.php         | 130 +++++++++++++++++++++++++++
 2 files changed, 144 insertions(+), 107 deletions(-)
 create mode 100644 app/Util/LanguageManager.php

diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php
index 16aea8ec9..d8bb296fc 100644
--- a/app/Http/Middleware/Localization.php
+++ b/app/Http/Middleware/Localization.php
@@ -2,59 +2,18 @@
 
 namespace BookStack\Http\Middleware;
 
+use BookStack\Util\LanguageManager;
 use Carbon\Carbon;
 use Closure;
-use Illuminate\Http\Request;
 
 class Localization
 {
-    /**
-     * Array of right-to-left locales.
-     */
-    protected $rtlLocales = ['ar', 'fa', 'he'];
+    protected LanguageManager $languageManager;
 
-    /**
-     * Map of BookStack locale names to best-estimate system locale names.
-     * Locales can often be found by running `locale -a` on a linux system.
-     */
-    protected $localeMap = [
-        'ar'          => 'ar',
-        'bg'          => 'bg_BG',
-        'bs'          => 'bs_BA',
-        'ca'          => 'ca',
-        'da'          => 'da_DK',
-        'de'          => 'de_DE',
-        'de_informal' => 'de_DE',
-        'en'          => 'en_GB',
-        'es'          => 'es_ES',
-        'es_AR'       => 'es_AR',
-        'et'          => 'et_EE',
-        'eu'          => 'eu_ES',
-        'fa'          => 'fa_IR',
-        'fr'          => 'fr_FR',
-        'he'          => 'he_IL',
-        'hr'          => 'hr_HR',
-        'id'          => 'id_ID',
-        'it'          => 'it_IT',
-        'ja'          => 'ja',
-        'ko'          => 'ko_KR',
-        'lt'          => 'lt_LT',
-        'lv'          => 'lv_LV',
-        'nl'          => 'nl_NL',
-        'nb'          => 'nb_NO',
-        'pl'          => 'pl_PL',
-        'pt'          => 'pt_PT',
-        'pt_BR'       => 'pt_BR',
-        'ru'          => 'ru',
-        'sk'          => 'sk_SK',
-        'sl'          => 'sl_SI',
-        'sv'          => 'sv_SE',
-        'uk'          => 'uk_UA',
-        'vi'          => 'vi_VN',
-        'zh_CN'       => 'zh_CN',
-        'zh_TW'       => 'zh_TW',
-        'tr'          => 'tr_TR',
-    ];
+    public function __construct(LanguageManager $languageManager)
+    {
+        $this->languageManager = $languageManager;
+    }
 
     /**
      * Handle an incoming request.
@@ -66,76 +25,24 @@ class Localization
      */
     public function handle($request, Closure $next)
     {
+        // Get and record the default language in the config
         $defaultLang = config('app.locale');
         config()->set('app.default_locale', $defaultLang);
 
-        $locale = $this->getUserLocale($request, $defaultLang);
-        config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
+        // Get the user's language and record that in the config for use in views
+        $userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
+        config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
 
         // Set text direction
-        if (in_array($locale, $this->rtlLocales)) {
+        if ($this->languageManager->isRTL($userLang)) {
             config()->set('app.rtl', true);
         }
 
-        app()->setLocale($locale);
-        Carbon::setLocale($locale);
-        $this->setSystemDateLocale($locale);
+        app()->setLocale($userLang);
+        Carbon::setLocale($userLang);
+        $this->languageManager->setPhpDateTimeLocale($userLang);
 
         return $next($request);
     }
 
-    /**
-     * Get the locale specifically for the currently logged in user if available.
-     */
-    protected function getUserLocale(Request $request, string $default): string
-    {
-        try {
-            $user = user();
-        } catch (\Exception $exception) {
-            return $default;
-        }
-
-        if ($user->isDefault() && config('app.auto_detect_locale')) {
-            return $this->autoDetectLocale($request, $default);
-        }
-
-        return setting()->getUser($user, 'language', $default);
-    }
-
-    /**
-     * Autodetect the visitors locale by matching locales in their headers
-     * against the locales supported by BookStack.
-     */
-    protected function autoDetectLocale(Request $request, string $default): string
-    {
-        $availableLocales = config('app.locales');
-        foreach ($request->getLanguages() as $lang) {
-            if (in_array($lang, $availableLocales)) {
-                return $lang;
-            }
-        }
-
-        return $default;
-    }
-
-    /**
-     * Get the ISO version of a BookStack language name.
-     */
-    public function getLocaleIso(string $locale): string
-    {
-        return $this->localeMap[$locale] ?? $locale;
-    }
-
-    /**
-     * Set the system date locale for localized date formatting.
-     * Will try both the standard locale name and the UTF8 variant.
-     */
-    protected function setSystemDateLocale(string $locale)
-    {
-        $systemLocale = $this->getLocaleIso($locale);
-        $set = setlocale(LC_TIME, $systemLocale);
-        if ($set === false) {
-            setlocale(LC_TIME, $systemLocale . '.utf8');
-        }
-    }
 }
diff --git a/app/Util/LanguageManager.php b/app/Util/LanguageManager.php
new file mode 100644
index 000000000..ff860c83d
--- /dev/null
+++ b/app/Util/LanguageManager.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Http\Request;
+
+class LanguageManager
+{
+    /**
+     * Array of right-to-left language options
+     */
+    protected array $rtlLanguages = ['ar', 'fa', 'he'];
+
+    /**
+     * Map of BookStack language names to best-estimate ISO and windows locale names.
+     * Locales can often be found by running `locale -a` on a linux system.
+     * Windows locales can be found at:
+     * https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170
+     *
+     * @var array<string, array{iso: string, windows: string}>
+     */
+    protected array $localeMap = [
+        'ar'          => ['iso' => 'ar', 'windows' => 'Arabic'],
+        'bg'          => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
+        'bs'          => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
+        'ca'          => ['iso' => 'ca', 'windows' => 'Catalan'],
+        'da'          => ['iso' => 'da_DK', 'windows' => 'Danish'],
+        'de'          => ['iso' => 'de_DE', 'windows' => 'German'],
+        'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
+        'en'          => ['iso' => 'en_GB', 'windows' => 'English'],
+        'es'          => ['iso' => 'es_ES', 'windows' => 'Spanish'],
+        'es_AR'       => ['iso' => 'es_AR', 'windows' => 'Spanish'],
+        'et'          => ['iso' => 'et_EE', 'windows' => 'Estonian'],
+        'eu'          => ['iso' => 'eu_ES', 'windows' => 'Basque'],
+        'fa'          => ['iso' => 'fa_IR', 'windows' => 'Persian'],
+        'fr'          => ['iso' => 'fr_FR', 'windows' => 'French'],
+        'he'          => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
+        'hr'          => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
+        'hu'          => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
+        'id'          => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
+        'it'          => ['iso' => 'it_IT', 'windows' => 'Italian'],
+        'ja'          => ['iso' => 'ja', 'windows' => 'Japanese'],
+        'ko'          => ['iso' => 'ko_KR', 'windows' => 'Korean'],
+        'lt'          => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
+        'lv'          => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
+        'nl'          => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
+        'nb'          => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
+        'pl'          => ['iso' => 'pl_PL', 'windows' => 'Polish'],
+        'pt'          => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
+        'pt_BR'       => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
+        'ru'          => ['iso' => 'ru', 'windows' => 'Russian'],
+        'sk'          => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
+        'sl'          => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
+        'sv'          => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
+        'uk'          => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
+        'vi'          => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
+        'zh_CN'       => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
+        'zh_TW'       => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
+        'tr'          => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
+    ];
+
+    /**
+     * Get the language specifically for the currently logged-in user if available.
+     */
+    public function getUserLanguage(Request $request, string $default): string
+    {
+        try {
+            $user = user();
+        } catch (\Exception $exception) {
+            return $default;
+        }
+
+        if ($user->isDefault() && config('app.auto_detect_locale')) {
+            return $this->autoDetectLocale($request, $default);
+        }
+
+        return setting()->getUser($user, 'language', $default);
+    }
+
+    /**
+     * Check if the given BookStack language value is a right-to-left language.
+     */
+    public function isRTL(string $language): bool
+    {
+        return in_array($language, $this->rtlLanguages);
+    }
+
+    /**
+     * Autodetect the visitors locale by matching locales in their headers
+     * against the locales supported by BookStack.
+     */
+    protected function autoDetectLocale(Request $request, string $default): string
+    {
+        $availableLocales = config('app.locales');
+        foreach ($request->getLanguages() as $lang) {
+            if (in_array($lang, $availableLocales)) {
+                return $lang;
+            }
+        }
+
+        return $default;
+    }
+
+    /**
+     * Get the ISO version of a BookStack language name.
+     */
+    public function getIsoName(string $language): string
+    {
+        return $this->localeMap[$language]['iso'] ?? $language;
+    }
+
+    /**
+     * Set the system date locale for localized date formatting.
+     * Will try both the standard locale name and the UTF8 variant.
+     */
+    public function setPhpDateTimeLocale(string $language)
+    {
+        $isoLang = $this->localeMap[$language]['iso'] ?? false;
+
+        $locales = array_filter([
+            $isoLang ? $isoLang . '.utf8' : false,
+            $isoLang ?: false,
+            $isoLang ? str_replace('_', '-', $isoLang) : false,
+                $this->localeMap[$language]['windows'] ?? false,
+            $language,
+        ]);
+
+        setlocale(LC_TIME, ...$locales);
+    }
+}
\ No newline at end of file