From cae140a348f7420ce02ec5ffa690f7ab7ef636b9 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 11 Jul 2026 22:20:05 +0800 Subject: [PATCH] feat: add browser extension language selector --- scripts/test-popup-settings.js | 58 +++++++++- shared/popup/popup.css | 11 ++ shared/popup/popup.html | 23 ++-- shared/popup/popup.js | 195 +++++++++++++++++++++++---------- 4 files changed, 219 insertions(+), 68 deletions(-) diff --git a/scripts/test-popup-settings.js b/scripts/test-popup-settings.js index f693ee9..062f4ad 100644 --- a/scripts/test-popup-settings.js +++ b/scripts/test-popup-settings.js @@ -20,6 +20,10 @@ class Element { click() { if (this.listeners.click) this.listeners.click(); } + + change() { + if (this.listeners.change) this.listeners.change({ target: this }); + } } const elements = {}; @@ -32,10 +36,23 @@ const elements = {}; 'file-input', 'pending-count', 'status-dot', + 'subtitle', + 'receiver-label', + 'pending-label', + 'url-label', 'capture-page', 'capture-file', 'retry', + 'file-label', + 'receiver-url-label', + 'receiver-token-label', + 'language-label', + 'language-select', + 'language-system-option', + 'language-en-option', + 'language-ru-option', 'save-settings', + 'context-menu-hint', ].forEach((id) => { elements[id] = new Element(); }); @@ -45,12 +62,14 @@ const initialState = { settings: { receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', receiverToken: 'persisted-token', + language: 'system', }, pendingCount: 0, status: {}, }; const browser = { runtime: { + getURL(relativePath) { return `extension://${relativePath}`; }, sendMessage(message) { if (message.action === 'getState') return Promise.resolve(initialState); if (message.action === 'saveSettings') { @@ -60,24 +79,56 @@ const browser = { return Promise.resolve(initialState); }, }, + i18n: { getUILanguage() { return 'ru-RU'; } }, }; const document = { activeElement: null, + documentElement: { lang: '' }, getElementById(id) { return elements[id]; }, }; +const catalogs = { + en: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'en.json'), 'utf8')), + ru: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'ru.json'), 'utf8')), +}; +function fetchCatalog(url) { + const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en'; + return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) }); +} +const i18nPath = path.join(__dirname, '..', 'shared', 'i18n.js'); const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js'); -vm.runInNewContext(fs.readFileSync(popupPath, 'utf8'), { browser, console, document, Promise, btoa }); +const context = vm.createContext({ browser, console, document, Promise, btoa, fetch: fetchCatalog, navigator: { language: 'en-US' } }); +context.globalThis = context; +vm.runInContext(fs.readFileSync(i18nPath, 'utf8'), context, { filename: i18nPath }); +vm.runInContext(fs.readFileSync(popupPath, 'utf8'), context, { filename: popupPath }); async function flush() { - for (let i = 0; i < 4; i += 1) await Promise.resolve(); + for (let i = 0; i < 16; i += 1) await Promise.resolve(); } (async () => { await flush(); assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl); assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken); + assert.strictEqual(elements['language-select'].value, 'system'); + assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу'); + assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно'); + assert.strictEqual(document.documentElement.lang, 'ru'); + elements['capture-file'].click(); + assert.strictEqual(elements.status.textContent, 'Сначала выберите файл'); + + elements['language-select'].value = 'en'; + elements['language-select'].change(); + await flush(); + + assert.strictEqual(elements['capture-page'].textContent, 'Send Page'); + assert.strictEqual(elements['receiver-state'].textContent, 'Unknown'); + assert.strictEqual(document.documentElement.lang, 'en'); + assert.ok(savedSettings); + assert.strictEqual(savedSettings.language, 'en'); + assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl); + assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken); elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; elements['receiver-token-input'].value = 'new-token'; @@ -87,7 +138,8 @@ async function flush() { assert.ok(savedSettings); assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); assert.strictEqual(savedSettings.receiverToken, 'new-token'); - console.log('browser extension popup settings tests passed'); + assert.strictEqual(savedSettings.language, 'en'); + console.log('browser extension popup localization/settings tests passed'); })().catch((error) => { console.error(error); process.exit(1); diff --git a/shared/popup/popup.css b/shared/popup/popup.css index b635990..daa6b3b 100644 --- a/shared/popup/popup.css +++ b/shared/popup/popup.css @@ -126,6 +126,17 @@ input { color: #e5e7eb; } +select { + box-sizing: border-box; + width: 100%; + margin-top: 5px; + border: 1px solid #374151; + border-radius: 4px; + padding: 7px 8px; + background: #0f172a; + color: #e5e7eb; +} + input[type="file"] { cursor: pointer; font-size: 12px; diff --git a/shared/popup/popup.html b/shared/popup/popup.html index 5f8969b..9818ab2 100644 --- a/shared/popup/popup.html +++ b/shared/popup/popup.html @@ -1,5 +1,5 @@ - + @@ -17,15 +17,15 @@
- Receiver + Receiver Unknown
- Pending + Pending 0
- URL + URL
@@ -37,21 +37,28 @@
- +
- + - + + +
-

Selection and link captures are available from the page context menu.

+

Selection and link captures are available from the page context menu.

+ diff --git a/shared/popup/popup.js b/shared/popup/popup.js index 9a86714..36d2b21 100644 --- a/shared/popup/popup.js +++ b/shared/popup/popup.js @@ -2,16 +2,90 @@ 'use strict'; var ext = typeof browser !== 'undefined' ? browser : chrome; + var i18n = globalThis.VerstakBrowserI18n; var statusEl = document.getElementById('status'); var receiverStateEl = document.getElementById('receiver-state'); var receiverUrlEl = document.getElementById('receiver-url'); var receiverInputEl = document.getElementById('receiver-input'); var receiverTokenInputEl = document.getElementById('receiver-token-input'); + var languageSelectEl = document.getElementById('language-select'); var fileInputEl = document.getElementById('file-input'); var pendingCountEl = document.getElementById('pending-count'); var statusDotEl = document.getElementById('status-dot'); var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_BYTES = 8 * 1024 * 1024; + var catalogs = { en: {}, ru: {} }; + var currentPreference = 'system'; + var currentState = {}; + var t = i18n.createTranslator(catalogs, 'en'); + + var staticText = { + subtitle: 'popup.subtitle', + 'receiver-label': 'label.receiver', + 'pending-label': 'label.pending', + 'url-label': 'label.url', + 'file-label': 'label.file', + 'receiver-url-label': 'label.receiverUrl', + 'receiver-token-label': 'label.pairingToken', + 'language-label': 'label.language', + 'capture-page': 'action.sendPage', + 'capture-file': 'action.sendFile', + retry: 'action.retryPending', + 'save-settings': 'action.save', + 'context-menu-hint': 'hint.contextMenu', + 'language-system-option': 'language.system', + 'language-en-option': 'language.en', + 'language-ru-option': 'language.ru' + }; + + function browserLocale() { + try { + if (ext.i18n && ext.i18n.getUILanguage) return ext.i18n.getUILanguage(); + } catch (_) {} + return typeof navigator !== 'undefined' ? navigator.language : 'en'; + } + + function loadCatalogs() { + return i18n.loadCatalogs(function (locale) { + return fetch(ext.runtime.getURL('locales/' + locale + '.json')).then(function (response) { + if (!response.ok) throw new Error('catalog load failed: ' + locale); + return response.json(); + }); + }).catch(function (error) { + console.warn('[verstak] localization catalogs unavailable:', error); + return { en: {}, ru: {} }; + }); + } + + function applyReceiverState(state) { + var reachable = state && state.status && state.status.receiverReachable; + if (reachable === true) { + receiverStateEl.textContent = t('receiver.online', null, 'Online'); + receiverStateEl.className = 'online'; + statusDotEl.className = 'dot online'; + } else if (reachable === false) { + receiverStateEl.textContent = t('receiver.offline', null, 'Offline'); + receiverStateEl.className = 'offline'; + statusDotEl.className = 'dot offline'; + } else { + receiverStateEl.textContent = t('receiver.unknown', null, 'Unknown'); + receiverStateEl.className = ''; + statusDotEl.className = 'dot unknown'; + } + } + + function applyLocale(preference) { + currentPreference = i18n.normalizePreference(preference); + var locale = i18n.resolveLocale(currentPreference, browserLocale()); + t = i18n.createTranslator(catalogs, locale); + document.documentElement.lang = locale; + languageSelectEl.value = currentPreference; + Object.keys(staticText).forEach(function (id) { + var element = document.getElementById(id); + if (element) element.textContent = t(staticText[id]); + }); + applyReceiverState(currentState); + } function arrayBufferToBase64(buffer) { var bytes = new Uint8Array(buffer); @@ -42,48 +116,58 @@ } function render(state) { - state = state || {}; - var settings = state.settings || {}; - var status = state.status || {}; - var reachable = status.receiverReachable; - pendingCountEl.textContent = String(state.pendingCount || 0); + currentState = state || {}; + var settings = currentState.settings || {}; + pendingCountEl.textContent = String(currentState.pendingCount || 0); receiverUrlEl.textContent = settings.receiverUrl || ''; - if (document.activeElement !== receiverInputEl) { - receiverInputEl.value = settings.receiverUrl || ''; - } - if (document.activeElement !== receiverTokenInputEl) { - receiverTokenInputEl.value = settings.receiverToken || ''; - } - - if (reachable === true) { - receiverStateEl.textContent = 'Online'; - receiverStateEl.className = 'online'; - statusDotEl.className = 'dot online'; - } else if (reachable === false) { - receiverStateEl.textContent = 'Offline'; - receiverStateEl.className = 'offline'; - statusDotEl.className = 'dot offline'; - } else { - receiverStateEl.textContent = 'Unknown'; - receiverStateEl.className = ''; - statusDotEl.className = 'dot unknown'; - } + if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || ''; + if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || ''; + applyReceiverState(currentState); } function refresh() { - return request({ type: 'verstak.capture', action: 'getState' }).then(render).catch(function (err) { - setStatus(err && err.message ? err.message : String(err)); + return request({ type: 'verstak.capture', action: 'getState' }).then(function (state) { + render(state); + applyLocale(state.settings && state.settings.language || 'system'); + return state; + }).catch(function (error) { + setStatus(error && error.message ? error.message : String(error)); + }); + } + + function currentSettings() { + return { + receiverUrl: receiverInputEl.value.trim(), + receiverToken: receiverTokenInputEl.value.trim(), + language: currentPreference + }; + } + + function saveCurrentSettings(successMessage) { + return request({ + type: 'verstak.capture', + action: 'saveSettings', + settings: currentSettings() + }).then(function (state) { + render(state); + if (successMessage) setStatus(t('status.saved', null, 'Saved')); + return state; + }).catch(function (error) { + setStatus(error && error.message ? error.message : String(error)); }); } function send(message) { - setStatus('Sending...'); + setStatus(t('status.sending', null, 'Sending...')); request(message).then(function (state) { render(state); - if (state.status && state.status.lastResult === 'queued') setStatus('Queued until Verstak is available'); - else setStatus('Done'); - }).catch(function (err) { - setStatus(err && err.message ? err.message : String(err)); + if (state.status && state.status.lastResult === 'queued') { + setStatus(t('status.queued', null, 'Queued until Verstak is available')); + } else { + setStatus(t('status.done', null, 'Done')); + } + }).catch(function (error) { + setStatus(error && error.message ? error.message : String(error)); }); } @@ -94,27 +178,26 @@ document.getElementById('capture-file').addEventListener('click', function () { var file = fileInputEl.files && fileInputEl.files[0]; if (!file) { - setStatus('Choose a text file first'); + setStatus(t('error.chooseFile', null, 'Choose a file first')); return; } if (file.size > MAX_FILE_BYTES) { - setStatus('File is too large for browser capture'); + setStatus(t('error.fileTooLarge', null, 'File is too large for browser capture')); return; } - setStatus('Reading file...'); + setStatus(t('status.readingFile', null, 'Reading file...')); Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) { - var content = results[1] || ''; send({ type: 'verstak.capture', kind: 'file', fileName: file.name, fileMime: file.type || '', fileSize: file.size, - fileText: content, + fileText: results[1] || '', fileDataBase64: arrayBufferToBase64(results[0]) }); - }).catch(function (err) { - setStatus(err && err.message ? err.message : String(err)); + }).catch(function (error) { + setStatus(error && error.message ? error.message : String(error)); }); }); @@ -122,24 +205,22 @@ send({ type: 'verstak.capture', action: 'retryPending' }); }); - document.getElementById('save-settings').addEventListener('click', function () { - var receiverUrl = receiverInputEl.value.trim(); - var receiverToken = receiverTokenInputEl.value.trim(); - if (!/^https?:\/\//.test(receiverUrl)) { - setStatus('Receiver URL must start with http:// or https://'); - return; - } - request({ - type: 'verstak.capture', - action: 'saveSettings', - settings: { receiverUrl: receiverUrl, receiverToken: receiverToken } - }).then(function (state) { - render(state); - setStatus('Saved'); - }).catch(function (err) { - setStatus(err && err.message ? err.message : String(err)); - }); + languageSelectEl.addEventListener('change', function () { + applyLocale(languageSelectEl.value); + saveCurrentSettings(true); }); - refresh(); + document.getElementById('save-settings').addEventListener('click', function () { + if (!/^https?:\/\//.test(receiverInputEl.value.trim())) { + setStatus(t('error.invalidReceiverUrl', null, 'Receiver URL must start with http:// or https://')); + return; + } + saveCurrentSettings(true); + }); + + loadCatalogs().then(function (loadedCatalogs) { + catalogs = loadedCatalogs; + applyLocale('system'); + return refresh(); + }); })();