ソースを参照

Merge branch 'add-zh-tw-locale'

El RIDO 6 日 前
コミット
e5059f8c33

+ 1 - 0
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 ## 2.1.0 (not yet released)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
+* ADDED: Translation for Chinese (Traditional)
 * CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
 * CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
 * CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.

+ 1 - 0
CREDITS.md

@@ -72,3 +72,4 @@
 * Edward205 - Romanian
 * babiloof - Swedish
 * Ali Fani - Persian
+* Peter Dave Hello - Chinese (Traditional)

+ 1 - 1
bin/configuration-test-generator

@@ -367,7 +367,7 @@ class ConfigurationTestGenerator
                         }
                         $args = array();
                         foreach ($test['args'] as $arg) {
-                            if (is_string($arg) && strpos($arg, '$') === 0) {
+                            if (is_string($arg) && str_starts_with($arg, '$')) {
                                 $args[] = $arg;
                             } else {
                                 $args[] = Helper::varExportMin($arg, true);

+ 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

@@ -51,7 +51,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'].concat(Object.keys(require('mime-db'))),
     formats = ['plaintext', 'markdown', 'syntaxhighlighting'];
 

+ 21 - 11
js/privatebin.js

@@ -492,11 +492,9 @@ window.PrivateBin = (function () {
             const name = cname + '=',
                 ca = document.cookie.split(';');
             for (let i = 0; i < ca.length; ++i) {
-                let c = ca[i];
-                while (c.charAt(0) === ' ') {
-                    c = c.substring(1);
-                }
-                if (c.indexOf(name) === 0) {
+                let c = ca[i].trim();
+                if (c.startsWith(name))
+                {
                     return c.substring(name.length, c.length);
                 }
             }
@@ -667,7 +665,7 @@ window.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
@@ -857,6 +855,7 @@ window.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));
@@ -893,8 +892,16 @@ window.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.startsWith('zh-tw-') ||
+                    newLanguage === 'zh-hant' || newLanguage.startsWith('zh-hant-') ||
+                    newLanguage === 'zh-hk' || newLanguage.startsWith('zh-hk-') ||
+                    newLanguage === 'zh-mo' || newLanguage.startsWith('zh-mo-');
+                // 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.includes('zh-tw')) {
+                    newLanguage = 'zh-tw';
+                } else if (!supportedLanguages.includes(newLanguage) && newLanguage.includes('-')) {
                     newLanguage = newLanguage.split('-')[0];
                 }
             }
@@ -911,7 +918,7 @@ window.PrivateBin = (function () {
             }
 
             // if language is not supported, show error
-            if (supportedLanguages.indexOf(newLanguage) === -1) {
+            if (!supportedLanguages.includes(newLanguage)) {
                 console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
                 language = 'en';
                 return;
@@ -963,7 +970,7 @@ window.PrivateBin = (function () {
         function isStringContainsHtml(messageId) {
             // message IDs are allowed to contain anchors, spans, keyboard and emphasis tags
             // we can recognize all of them by only checking for anchors and keyboard tags
-            return typeof messageId === 'string' && (messageId.indexOf('<a') !== -1 || messageId.indexOf('<kbd') !== -1);
+            return typeof messageId === 'string' && (messageId.includes('<a') || messageId.includes('<kbd'));
         }
 
         return me;
@@ -1486,7 +1493,7 @@ window.PrivateBin = (function () {
          * @return {bool}
          */
         me.hasDeleteToken = function () {
-            return window.location.search.indexOf('deletetoken') !== -1;
+            return window.location.search.includes('deletetoken');
         };
 
         /**
@@ -3156,6 +3163,9 @@ window.PrivateBin = (function () {
         me.getAttachmentMimeType = function (attachmentData) {
             // position in data URI string of where mimeType ends
             const mimeTypeEnd = attachmentData.indexOf(';');
+            if (mimeTypeEnd < 6) {
+                return '';
+            }
 
             // extract mimeType
             return attachmentData.substring(5, mimeTypeEnd).toLowerCase();

+ 4 - 2
js/test/CopyToClipboard.js

@@ -3,6 +3,8 @@ const common = require('../common');
 const fc = require('fast-check');
 
 describe('CopyToClipboard', function () {
+    this.timeout(30000);
+
     afterEach(() => {
         globalThis.cleanup();
     });
@@ -43,7 +45,7 @@ describe('CopyToClipboard', function () {
 
                     return text === savedToClipboardText;
                 }
-            ));
+            ), { numRuns: 20 });
         });
 
         /**
@@ -85,7 +87,7 @@ describe('CopyToClipboard', function () {
 
                     return copiedTextWithoutSelectedText === text;
                 }
-            ));
+            ), { numRuns: 20 });
         });
 
         /**

+ 42 - 0
js/test/I18n.js

@@ -216,6 +216,48 @@ describe('I18n', function () {
             ));
         });
 
+        it('maps Traditional Chinese browser languages to zh-tw', async function () {
+            const clean = globalThis.cleanup('<script src="js/privatebin.js"></script>', {url: 'https://privatebin.net/'});
+
+            // loadTranslations() loads the translation file via fetch() API,
+            // which is asynchronous. Stub it, so the test does not perform a
+            // real network request, and await the 'languageLoaded' event that
+            // loadTranslations() dispatches once the fetch has completed.
+            const originalFetch = globalThis.fetch;
+            globalThis.fetch = function (url) {
+                assert.strictEqual(url, 'i18n/zh-tw.json');
+                return Promise.resolve({
+                    ok: true,
+                    status: 200,
+                    statusText: 'OK',
+                    json: function () {
+                        return Promise.resolve({});
+                    }
+                });
+            };
+
+            try {
+                for (const language of ['zh-TW', 'zh-TW-u-nu-hanidec', 'zh-Hant', 'zh-Hant-TW', 'zh-HK', 'zh-MO']) {
+                    Object.defineProperty(navigator, 'language', {
+                        value: language,
+                        configurable: true
+                    });
+                    PrivateBin.I18n.reset('en');
+                    const loaded = new Promise(function (resolve) {
+                        document.addEventListener('languageLoaded', resolve, {once: true});
+                    });
+                    PrivateBin.I18n.loadTranslations();
+                    await loaded;
+                    assert.strictEqual(PrivateBin.I18n.getLanguage(), 'zh-tw', language);
+                }
+            } finally {
+                globalThis.fetch = originalFetch;
+                delete navigator.language;
+                PrivateBin.I18n.reset();
+                clean();
+            }
+        });
+
         it('should default to en', () => {
             var clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
 

+ 1 - 1
lib/Configuration.php

@@ -124,7 +124,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-PWgNCOcRqGGY+BYvdaSKWMcnG+4OOphh8b0jYrvWMS9WJDodn55CKGmLBtEBnkbDh1sDqB0m3f6hgtWXckhVqw==',
+            'js/privatebin.js'       => 'sha512-EDBvid7ZFsTiqmEbYUR2Bwo7ypn7GKf+JwW6VFvdE6qLQbzdKrAla+AKhONnt/Tve3zEPc9bXI+4hUHl5itdZw==',
             'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',

+ 4 - 1
lib/Controller.php

@@ -208,7 +208,10 @@ 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) ||
+            $lang === 'en'
+        )) {
             $_COOKIE['lang'] = $lang;
             setcookie('lang', $lang, ['SameSite' => 'Lax', 'Secure' => true]);
         }

+ 41 - 3
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' || str_starts_with($languageRange, 'zh-hans-') ||
+                    $languageRange === 'zh-cn' || str_starts_with($languageRange, 'zh-cn-') ||
+                    $languageRange === 'zh-sg' || str_starts_with($languageRange, 'zh-sg-');
+                $isTraditionalChinese = str_starts_with($languageRange, 'zh-tw-') ||
+                    $languageRange === 'zh-hant' || str_starts_with($languageRange, 'zh-hant-') ||
+                    $languageRange === 'zh-hk' || str_starts_with($languageRange, 'zh-hk-') ||
+                    $languageRange === 'zh-mo' || str_starts_with($languageRange, 'zh-mo-');
+                if ($hasSimplifiedChinese && $isSimplifiedChinese) {
+                    $languages[$quality][$index] = 'zh';
+                } elseif ($hasTraditionalChinese && $isTraditionalChinese) {
+                    $languages[$quality][$index] = 'zh-tw';
+                }
+            }
+        }
+        return $languages;
+    }
+
     /**
      * get currently loaded language
      *
@@ -306,8 +343,8 @@ class I18n
      */
     public static function getCopyHotkey()
     {
-        return isset($_SERVER['HTTP_USER_AGENT']) &&
-            strpos($_SERVER['HTTP_USER_AGENT'], 'Mac') !== false ? self::_('Cmd') : self::_('Ctrl');
+        return array_key_exists('HTTP_USER_AGENT', $_SERVER) &&
+            str_contains($_SERVER['HTTP_USER_AGENT'], 'Mac') ? self::_('Cmd') : self::_('Ctrl');
     }
 
     /**
@@ -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
      */

+ 26 - 2
tst/I18nTest.php

@@ -125,6 +125,27 @@ class I18nTest extends TestCase
         $this->assertEquals('2 小时', I18n::_('%d hours', 2), '2 hours in Chinese');
     }
 
+    public function testBrowserLanguageZhHantDetection()
+    {
+        foreach ([
+            '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';
@@ -210,8 +231,8 @@ class I18nTest extends TestCase
         // For example, the French translation should not have the apostrophe encoded
         // See https://github.com/PrivateBin/PrivateBin/issues/1712
         $message = I18n::_('Document does not exist, has expired or has been deleted.');
-        $this->assertFalse(strpos($message, '&apos;') !== false, 'French apostrophe should not be encoded in translation message');
-        $this->assertTrue(strpos($message, "n'existe") !== false, 'French apostrophe should be present as literal character');
+        $this->assertFalse(str_contains($message, '&apos;'), 'French apostrophe should not be encoded in translation message');
+        $this->assertTrue(str_contains($message, "n'existe"), 'French apostrophe should be present as literal character');
     }
 
     public function testFallbackAlwaysPresent()
@@ -224,6 +245,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;
@@ -284,6 +306,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;
             }