瀏覽代碼

Add Traditional Chinese (Taiwan) locale (zh-tw)

Peter Dave Hello 2 月之前
父節點
當前提交
9bf106b75e
共有 9 個文件被更改,包括 368 次插入6 次删除
  1. 1 0
      i18n/languages.json
  2. 238 0
      i18n/zh-tw.json
  3. 1 1
      js/common.js
  4. 12 3
      js/privatebin.js
  5. 30 0
      js/test/I18n.js
  6. 1 1
      lib/Controller.php
  7. 39 1
      lib/I18n.php
  8. 22 0
      tst/ControllerTest.php
  9. 24 0
      tst/I18nTest.php

+ 1 - 0
i18n/languages.json

@@ -30,6 +30,7 @@
     "ce": ["нохчийн мотт", "Chechen"],
     "ny": ["chiCheŵa", "Chichewa"],
     "zh": ["中文", "Chinese"],
+    "zh-tw": ["繁體中文", "Chinese Traditional"],
     "cv": ["чӑваш чӗлхи", "Chuvash"],
     "kw": ["Kernewek", "Cornish"],
     "co": ["corsu", "Corsican"],

+ 238 - 0
i18n/zh-tw.json

@@ -0,0 +1,238 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s 是一個極簡、開放原始碼的線上剪貼簿,伺服器對儲存的內容一無所知。所有資料皆%s在瀏覽器中%s以 AES-256 進行加、解密。",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "更多資訊請參閱<a href=\"https://privatebin.info/\">專案頁面</a>。",
+    "Because ignorance is bliss": "無知就是福",
+    "Document does not exist, has expired or has been deleted.": "文件不存在、已過期或已被刪除。",
+    "%s requires php %s or above to work. Sorry.": "%s 需要 PHP %s 或以上版本才能運作,造成不便敬請見諒。",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s 需要在組態檔中設定 [%s] 區段。",
+    "Please wait %d seconds between each post.": [
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。"
+    ],
+    "Document is limited to %s of encrypted data.": "加密資料大小上限為 %s。",
+    "Invalid data.": "無效的資料。",
+    "You are unlucky. Try again.": "操作失敗,請再試一次。",
+    "Error saving comment. Sorry.": "儲存留言時發生錯誤,敬請見諒。",
+    "Error saving document. Sorry.": "儲存文件時發生錯誤,敬請見諒。",
+    "Invalid document ID.": "無效的文件 ID。",
+    "Document is not of burn-after-reading type.": "此文件並非閱後即焚類型。",
+    "Wrong deletion token. Document was not deleted.": "刪除權杖錯誤,文件未被刪除。",
+    "Document was properly deleted.": "文件已成功刪除。",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s 需要 JavaScript 才能運作,造成不便敬請見諒。",
+    "%s requires a modern browser to work.": "%s 需要新式瀏覽器才能運作。",
+    "New": "新增",
+    "Create": "建立",
+    "Clone": "建立副本",
+    "Raw text": "原始文字",
+    "Expires": "到期時間",
+    "Burn after reading": "閱後即焚",
+    "Open discussion": "開放討論",
+    "Password (recommended)": "密碼 (建議使用)",
+    "Discussion": "討論",
+    "Toggle navigation": "切換導覽列",
+    "%d seconds": [
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒"
+    ],
+    "%d minutes": [
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘"
+    ],
+    "%d hours": [
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時"
+    ],
+    "%d days": [
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天"
+    ],
+    "%d weeks": [
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週"
+    ],
+    "%d months": [
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月"
+    ],
+    "%d years": [
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年"
+    ],
+    "Never": "永不過期",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "注意:這是測試服務,資料可能隨時被刪除。如果濫用此服務,小貓咪會遭殃的。",
+    "This document will expire in %d seconds.": [
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。"
+    ],
+    "This document will expire in %d minutes.": [
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。"
+    ],
+    "This document will expire in %d hours.": [
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。"
+    ],
+    "This document will expire in %d days.": [
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。"
+    ],
+    "This document will expire in %d months.": [
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。"
+    ],
+    "Please enter the password for this document:": "請輸入此文件的密碼:",
+    "Could not decrypt data (Wrong key?)": "無法解密資料 (金鑰錯誤?)",
+    "Could not delete the document, it was not stored in burn after reading mode.": "無法刪除此文件,因為並非以閱後即焚模式儲存。",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "僅供閱覽一次。請勿關閉此視窗,此訊息將無法再次顯示。",
+    "Could not decrypt comment; Wrong key?": "無法解密留言;金鑰錯誤?",
+    "Reply": "回覆",
+    "Anonymous": "匿名",
+    "Avatar generated from IP address": "根據 IP 位址產生的頭像",
+    "Add comment": "新增留言",
+    "Optional nickname…": "暱稱 (選填)…",
+    "Post comment": "發表留言",
+    "Sending comment…": "正在傳送留言…",
+    "Comment posted.": "留言已發表。",
+    "Could not refresh display: %s": "無法重新整理顯示內容:%s",
+    "unknown status": "未知狀態",
+    "server error or not responding": "伺服器錯誤或無回應",
+    "Could not post comment: %s": "無法發表留言:%s",
+    "Sending document…": "正在傳送文件…",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "文件連結為 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按 <kbd>%s</kbd>+<kbd>c</kbd> 複製)</span>",
+    "Delete data": "刪除資料",
+    "Could not create document: %s": "無法建立文件:%s",
+    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "無法解密文件:URL 中缺少解密金鑰 (是否使用了會截斷 URL 的重新導向服務或短網址服務?)",
+    "B": "B",
+    "kB": "kB",
+    "MB": "MB",
+    "GB": "GB",
+    "TB": "TB",
+    "PB": "PB",
+    "EB": "EB",
+    "ZB": "ZB",
+    "YB": "YB",
+    "Format": "格式",
+    "Plain Text": "純文字",
+    "Source Code": "原始碼",
+    "Markdown": "Markdown",
+    "Download attachment": "下載附件",
+    "Cloned: '%s'": "已建立副本:「%s」",
+    "The cloned file '%s' was attached to this document.": "已將副本檔案「%s」附加到此文件。",
+    "Attach a file": "附加檔案",
+    "alternatively drag & drop a file or paste an image from the clipboard": "或拖放檔案,亦可從剪貼簿貼上圖片",
+    "File too large, to display a preview. Please download the attachment.": "檔案過大,無法顯示預覽。請下載附件。",
+    "Remove attachment": "移除附件",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "瀏覽器不支援上傳加密檔案,請使用較新的瀏覽器。",
+    "Invalid attachment.": "無效的附件。",
+    "Options": "選項",
+    "Shorten URL": "縮短網址",
+    "Editor": "編輯器",
+    "Preview": "預覽",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s 的 PATH 必須以「%s」結尾,請更新 index.php 中的 PATH。",
+    "Decrypt": "解密",
+    "Enter password": "輸入密碼",
+    "Loading…": "載入中…",
+    "Decrypting document…": "正在解密文件…",
+    "Preparing new document…": "正在準備新文件…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "若此訊息持續顯示,請參閱<a href=\"%s\">常見問題的疑難排解說明</a>。",
+    "+++ no document text +++": "+++ 無文件內容 +++",
+    "Could not get document data: %s": "無法取得文件資料:%s",
+    "QR code": "QR 碼",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "此網站正在使用不安全的 HTTP 連線!請僅用於測試。",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "如需更多資訊,請參閱<a href=\"%s\">此常見問題</a>。",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "瀏覽器可能需要 HTTPS 連線才能支援 WebCrypto API。請嘗試<a href=\"%s\">切換至 HTTPS</a>。",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "瀏覽器不支援用於 zlib 壓縮的 WebAssembly。可以建立未壓縮的文件,但無法讀取已壓縮的文件。",
+    "waiting on user to provide a password": "等待輸入密碼",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "無法解密資料。是否輸入了錯誤的密碼?請按上方的 [重試] 按鈕重新嘗試。",
+    "Retry": "重試",
+    "Showing raw text…": "正在顯示原始文字…",
+    "Notice:": "注意:",
+    "This link will expire after %s.": "此連結將於 %s 後過期。",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "此連結僅能存取一次,請勿使用瀏覽器的 [上一頁] 或 [重新整理] 按鈕。",
+    "Link:": "連結:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "收件者可能會得知您的時區,是否將時間轉換為 UTC?",
+    "Use Current Timezone": "使用目前時區",
+    "Convert To UTC": "轉換為 UTC",
+    "Close": "關閉",
+    "Encrypted note on %s": "%s 上的加密筆記",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "請造訪此連結檢視筆記。將此 URL 提供給任何人即可讓對方存取這則筆記。",
+    "URL shortener may expose your decrypt key in URL.": "短網址服務可能會在 URL 中暴露解密金鑰。",
+    "URL shortener is enabled by default.": "預設已啟用短網址功能。",
+    "Save document": "儲存文件",
+    "Your IP is not authorized to create documents.": "您的 IP 無權建立文件。",
+    "Trying to shorten a URL that isn't pointing at our instance.": "嘗試縮短的 URL 並非指向本站。",
+    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "代理伺服器錯誤:代理伺服器 URL 為空。這可能是組態問題,例如組態參數錯誤或遺失。",
+    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "代理伺服器錯誤:剖析代理伺服器回應時發生錯誤。這可能是組態問題,例如組態參數錯誤或遺失。",
+    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "代理伺服器錯誤:回應無效。這可能是組態問題,例如組態參數錯誤或遺失,或是暫時性中斷。",
+    "This secret message can only be displayed once. Would you like to see it now?": "這則機密訊息僅能顯示一次。是否立即檢視?",
+    "Yes, see it": "是,立即檢視",
+    "Dark Mode": "深色模式",
+    "Error compressing document, due to missing WebAssembly support.": "由於缺少 WebAssembly 支援,壓縮文件時發生錯誤。",
+    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "解壓縮文件時發生錯誤,瀏覽器不支援 WebAssembly。請使用其他瀏覽器檢視此文件。",
+    "Start over": "重新開始",
+    "Document copied to clipboard": "文件已複製到剪貼簿",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "若要複製文件,請按 [複製] 按鈕或使用快速鍵 <kbd>%s</kbd>+<kbd>c</kbd>",
+    "Copy link": "複製連結",
+    "Link copied to clipboard": "連結已複製到剪貼簿",
+    "Document text": "文件內容",
+    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "Tab 鍵作為字元輸入 (按 <kbd>Ctrl</kbd>+<kbd>m</kbd> 或 <kbd>Esc</kbd> 切換)",
+    "Show password": "顯示密碼",
+    "Hide password": "隱藏密碼",
+    "Theme": "佈景主題",
+    "Copy": "複製",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
+}

+ 1 - 1
js/common.js

@@ -34,7 +34,7 @@ var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
         })
     ),
     schemas = ['ftp','http','https'],
-    supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sk', 'sl', 'th', 'tr', 'uk', 'zh'],
+    supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sk', 'sl', 'th', 'tr', 'uk', 'zh', 'zh-tw'],
     mimeTypes = ['image/png', 'application/octet-stream'],
     formats = ['plaintext', 'markdown', 'syntaxhighlighting'],
     mimeFile = fs.createReadStream('/etc/mime.types'),

+ 12 - 3
js/privatebin.js

@@ -688,7 +688,7 @@ jQuery.PrivateBin = (function($) {
          * @prop   {string[]}
          * @readonly
          */
-        const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh'];
+        const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh', 'zh-tw'];
 
         /**
          * built in language
@@ -881,6 +881,7 @@ jQuery.PrivateBin = (function($) {
                 case 'oc':
                 case 'tr':
                 case 'zh':
+                case 'zh-tw':
                     return n > 1 ? 1 : 0;
                 case 'he':
                     return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
@@ -918,8 +919,16 @@ jQuery.PrivateBin = (function($) {
 
             // auto-select language based on browser settings
             if (newLanguage.length === 0) {
-                newLanguage = (navigator.language || navigator.userLanguage || 'en');
-                if (newLanguage.indexOf('-') > 0) {
+                newLanguage = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
+                const isTraditionalChinese = newLanguage.indexOf('zh-tw-') === 0 ||
+                    newLanguage === 'zh-hant' || newLanguage.indexOf('zh-hant-') === 0 ||
+                    newLanguage === 'zh-hk' || newLanguage.indexOf('zh-hk-') === 0 ||
+                    newLanguage === 'zh-mo' || newLanguage.indexOf('zh-mo-') === 0;
+                // alias Traditional Chinese browser tags to PrivateBin's zh-tw locale;
+                // otherwise try the full tag and fall back to the base language
+                if (isTraditionalChinese && supportedLanguages.indexOf('zh-tw') !== -1) {
+                    newLanguage = 'zh-tw';
+                } else if (supportedLanguages.indexOf(newLanguage) === -1 && newLanguage.indexOf('-') > 0) {
                     newLanguage = newLanguage.split('-')[0];
                 }
             }

+ 30 - 0
js/test/I18n.js

@@ -204,6 +204,36 @@ describe('I18n', function () {
             }
         );
 
+        it('maps Traditional Chinese browser languages to zh-tw', function () {
+            var clean = jsdom('<script src="js/privatebin.js"></script>', {url: 'https://privatebin.net/'}),
+                getJSON = $.getJSON;
+
+            $.getJSON = function(url, callback) {
+                assert.strictEqual(url, 'i18n/zh-tw.json');
+                callback({});
+                return {
+                    fail: function() {}
+                };
+            };
+
+            try {
+                ['zh-TW', 'zh-TW-u-nu-hanidec', 'zh-Hant', 'zh-Hant-TW', 'zh-HK', 'zh-MO'].forEach(function(language) {
+                    Object.defineProperty(navigator, 'language', {
+                        value: language,
+                        configurable: true
+                    });
+                    $.PrivateBin.I18n.reset('en');
+                    $.PrivateBin.I18n.loadTranslations();
+                    assert.strictEqual($.PrivateBin.I18n.getLanguage(), 'zh-tw', language);
+                });
+            } finally {
+                $.getJSON = getJSON;
+                delete navigator.language;
+                $.PrivateBin.I18n.reset();
+                clean();
+            }
+        });
+
         jsc.property(
             'should default to en',
             function() {

+ 1 - 1
lib/Controller.php

@@ -203,7 +203,7 @@ class Controller
         $lang = $this->_conf->getKey('languagedefault');
         I18n::setLanguageFallback($lang);
         // force default language, if language selection is disabled and a default is set
-        if (!$this->_conf->getKey('languageselection') && strlen($lang) === 2) {
+        if (!$this->_conf->getKey('languageselection') && in_array($lang, I18n::getAvailableLanguages(), true)) {
             $_COOKIE['lang'] = $lang;
             setcookie('lang', $lang, ['SameSite' => 'Lax', 'Secure' => true]);
         }

+ 39 - 1
lib/I18n.php

@@ -177,7 +177,10 @@ class I18n
         // find a translation file matching the browsers language preferences
         else {
             self::$_language = self::_getMatchingLanguage(
-                self::getBrowserLanguages(), $availableLanguages
+                self::_normalizeBrowserLanguages(
+                    self::getBrowserLanguages(), $availableLanguages
+                ),
+                $availableLanguages
             );
         }
 
@@ -204,6 +207,7 @@ class I18n
             $languageIterator            = new AppendIterator();
             $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
             $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
+            $languageIterator->append(new GlobIterator(self::_getPath('??-??.json'))); // for regional variants like zh-tw
             foreach ($languageIterator as $file) {
                 $language = $file->getBasename('.json');
                 if ($language !== 'en') {
@@ -249,6 +253,39 @@ class I18n
         return $languages;
     }
 
+    /**
+     * normalize browser language aliases to available locale IDs
+     *
+     * @access protected
+     * @static
+     * @param  array $languages
+     * @param  array $availableLanguages
+     * @return array
+     */
+    protected static function _normalizeBrowserLanguages($languages, $availableLanguages)
+    {
+        // Base zh stays before zh-tw because regional locales are appended after base locales.
+        $hasSimplifiedChinese  = in_array('zh', $availableLanguages, true);
+        $hasTraditionalChinese = in_array('zh-tw', $availableLanguages, true);
+        foreach ($languages as $quality => $languageRanges) {
+            foreach ($languageRanges as $index => $languageRange) {
+                $isSimplifiedChinese = $languageRange === 'zh-hans' || strpos($languageRange, 'zh-hans-') === 0 ||
+                    $languageRange === 'zh-cn' || strpos($languageRange, 'zh-cn-') === 0 ||
+                    $languageRange === 'zh-sg' || strpos($languageRange, 'zh-sg-') === 0;
+                $isTraditionalChinese = strpos($languageRange, 'zh-tw-') === 0 ||
+                    $languageRange === 'zh-hant' || strpos($languageRange, 'zh-hant-') === 0 ||
+                    $languageRange === 'zh-hk' || strpos($languageRange, 'zh-hk-') === 0 ||
+                    $languageRange === 'zh-mo' || strpos($languageRange, 'zh-mo-') === 0;
+                if ($hasSimplifiedChinese && $isSimplifiedChinese) {
+                    $languages[$quality][$index] = 'zh';
+                } elseif ($hasTraditionalChinese && $isTraditionalChinese) {
+                    $languages[$quality][$index] = 'zh-tw';
+                }
+            }
+        }
+        return $languages;
+    }
+
     /**
      * get currently loaded language
      *
@@ -364,6 +401,7 @@ class I18n
             case 'oc':
             case 'tr':
             case 'zh':
+            case 'zh-tw':
                 return $n > 1 ? 1 : 0;
             case 'he':
                 return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));

+ 22 - 0
tst/ControllerTest.php

@@ -115,6 +115,28 @@ class ControllerTest extends TestCase
         );
     }
 
+    /**
+     * @runInSeparateProcess
+     */
+    public function testViewForceRegionalLanguageDefault()
+    {
+        $options                              = parse_ini_file(CONF, true);
+        $options['main']['languageselection'] = false;
+        $options['main']['languagedefault']   = 'zh-tw';
+        Helper::createIniFile(CONF, $options);
+        $_COOKIE['lang'] = 'de';
+        ob_start();
+        new Controller;
+        $content = ob_get_contents();
+        ob_end_clean();
+        $this->assertStringContainsString(
+            '<title>PrivateBin</title>',
+            $content,
+            'outputs title correctly'
+        );
+        $this->assertSame('zh-tw', $_COOKIE['lang'], 'forces configured default language');
+    }
+
     /**
      * @runInSeparateProcess
      */

+ 24 - 0
tst/I18nTest.php

@@ -110,6 +110,27 @@ class I18nTest extends TestCase
         $this->assertEquals('2 小时', I18n::_('%d hours', 2), '2 hours in Chinese');
     }
 
+    public function testBrowserLanguageZhHantDetection()
+    {
+        foreach (array(
+            'zh-Hant,zh;q=0.8,en;q=0.2'            => 'zh-tw',
+            'zh-Hant-TW,zh;q=0.8,en;q=0.2'         => 'zh-tw',
+            'zh-TW,en;q=0.2'                       => 'zh-tw',
+            'zh-TW-u-nu-hanidec,en;q=0.2'          => 'zh-tw',
+            'zh-HK,zh;q=0.8,en;q=0.2'              => 'zh-tw',
+            'zh-MO,zh;q=0.8,en;q=0.2'              => 'zh-tw',
+            'zh-Hant,zh-Hans;q=0.8,en;q=0.2'       => 'zh-tw',
+            'zh-CN,zh-Hant;q=0.8,en;q=0.2'         => 'zh',
+            'zh-Hans,zh-Hant;q=0.8,en;q=0.2'       => 'zh',
+            'zh-Hans-CN,en;q=0.2'                  => 'zh',
+            'zh;q=0.9,zh-Hant;q=0.8,en;q=0.2'      => 'zh',
+        ) as $acceptedLanguage => $language) {
+            $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $acceptedLanguage;
+            I18n::loadTranslations();
+            $this->assertEquals($language, I18n::getLanguage(), 'browser language ' . $acceptedLanguage);
+        }
+    }
+
     public function testBrowserLanguagePlDetection()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'pl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
@@ -209,6 +230,7 @@ class I18nTest extends TestCase
         $languageIterator = new AppendIterator();
         $languageIterator->append(new GlobIterator(I18nMock::getPath('??.json')));
         $languageIterator->append(new GlobIterator(I18nMock::getPath('???.json'))); // for jbo
+        $languageIterator->append(new GlobIterator(I18nMock::getPath('??-??.json'))); // for regional variants like zh-tw
         $languageCount = 0;
         foreach ($languageIterator as $file) {
             ++$languageCount;
@@ -256,6 +278,8 @@ class I18nTest extends TestCase
                 $language = substr($file->getFilename(), 0, 2);
             } elseif ($fileNameLength === 8) { // jbo.json
                 $language = substr($file->getFilename(), 0, 3);
+            } elseif ($fileNameLength === 10) { // xx-xx.json
+                $language = substr($file->getFilename(), 0, 5);
             } else {
                 continue;
             }