From 766458d0a0ecb2049164cd8401176d8a809676c1 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 11 Jul 2026 22:17:19 +0800 Subject: [PATCH] feat: localize browser extension context menus --- firefox/manifest.json | 2 +- package.json | 2 +- scripts/test-background-i18n.js | 119 ++++++++++++++++++++++++++++++++ shared/background.js | 54 ++++++++++++--- 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 scripts/test-background-i18n.js diff --git a/firefox/manifest.json b/firefox/manifest.json index 28c30c2..15c56cb 100644 --- a/firefox/manifest.json +++ b/firefox/manifest.json @@ -17,7 +17,7 @@ }, "permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], "background": { - "scripts": ["protocol.js", "api.js", "queue.js", "background.js"] + "scripts": ["protocol.js", "api.js", "queue.js", "i18n.js", "background.js"] }, "browser_action": { "default_popup": "popup/popup.html", diff --git a/package.json b/package.json index 318fa93..4cb5fee 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Verstak browser capture extension for Chromium and Firefox", "scripts": { "build": "node scripts/build-extension.js", - "test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js", + "test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-background-i18n.js", "sign:firefox": "./scripts/sign-firefox-xpi.sh", "release:firefox": "./scripts/release-firefox-xpi.sh" }, diff --git a/scripts/test-background-i18n.js b/scripts/test-background-i18n.js new file mode 100644 index 0000000..e2ea9e5 --- /dev/null +++ b/scripts/test-background-i18n.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const catalogs = { + en: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'en.json'), 'utf8')), + ru: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'ru.json'), 'utf8')), +}; +const state = { + settings: { + receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', + receiverToken: 'paired-token', + language: 'ru', + }, +}; +let menuTitles = []; +let installedListener; +let messageListener; + +const browser = { + storage: { + local: { + get(key) { + if (typeof key === 'string') return Promise.resolve({ [key]: state[key] }); + return Promise.resolve({ ...state }); + }, + set(patch) { + Object.assign(state, patch || {}); + return Promise.resolve(); + }, + }, + }, + contextMenus: { + removeAll(callback) { + menuTitles = []; + if (callback) callback(); + }, + create(item) { + menuTitles.push(item.title); + }, + onClicked: { addListener() {} }, + }, + runtime: { + getURL(relativePath) { return `extension://${relativePath}`; }, + onInstalled: { addListener(listener) { installedListener = listener; } }, + onMessage: { addListener(listener) { messageListener = listener; } }, + }, + i18n: { getUILanguage() { return 'en-US'; } }, + tabs: { query() { return Promise.resolve([]); } }, +}; + +function fetchCatalog(url) { + const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en'; + return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) }); +} + +const context = vm.createContext({ + browser, + console, + Date, + Math, + Promise, + URL, + fetch: fetchCatalog, + setTimeout, + clearTimeout, +}); +context.globalThis = context; +for (const file of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) { + vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file }); +} + +async function flush() { + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + +function sendMessage(message) { + return new Promise((resolve) => { + messageListener(message, {}, resolve); + }); +} + +(async () => { + installedListener(); + await flush(); + assert.deepStrictEqual(menuTitles, [ + 'Отправить страницу в Верстак', + 'Отправить выделение в Верстак', + 'Отправить ссылку в Верстак', + ]); + + const nextState = await sendMessage({ + type: 'verstak.capture', + action: 'saveSettings', + settings: { + receiverUrl: state.settings.receiverUrl, + receiverToken: state.settings.receiverToken, + language: 'en', + }, + }); + await flush(); + + assert.strictEqual(state.settings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); + assert.strictEqual(state.settings.receiverToken, 'paired-token'); + assert.strictEqual(state.settings.language, 'en'); + assert.strictEqual(nextState.settings.language, 'en'); + assert.deepStrictEqual(menuTitles, [ + 'Send page to Verstak', + 'Send selection to Verstak', + 'Send link to Verstak', + ]); + console.log('browser extension background localization tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/shared/background.js b/shared/background.js index b08f676..5e84e99 100644 --- a/shared/background.js +++ b/shared/background.js @@ -4,20 +4,49 @@ var ext = typeof browser !== 'undefined' ? browser : chrome; var protocol = globalThis.VerstakBrowser; var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext)); + var i18n = globalThis.VerstakBrowserI18n; var DEFAULT_SETTINGS = { receiverUrl: protocol.DEFAULT_RECEIVER_URL, - receiverToken: '' + receiverToken: '', + language: 'system' }; var STATUS_KEY = 'verstak.status'; + var localeCatalogs = null; function getSettings() { return ext.storage.local.get('settings').then(function (result) { - return Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); + var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); + settings.language = i18n.normalizePreference(settings.language); + return settings; }); } function saveSettings(settings) { - return ext.storage.local.set({ settings: Object.assign({}, DEFAULT_SETTINGS, settings || {}) }); + settings = Object.assign({}, DEFAULT_SETTINGS, settings || {}); + settings.language = i18n.normalizePreference(settings.language); + return ext.storage.local.set({ settings: settings }); + } + + function loadLocaleCatalogs() { + if (localeCatalogs) return localeCatalogs; + localeCatalogs = 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: {} }; + }); + return localeCatalogs; + } + + function browserLocale() { + try { + return ext.i18n && ext.i18n.getUILanguage ? ext.i18n.getUILanguage() : 'en'; + } catch (_) { + return 'en'; + } } function setStatus(patch) { @@ -105,11 +134,18 @@ } function setupContextMenus() { - if (!ext.contextMenus) return; - ext.contextMenus.removeAll(function () { - ext.contextMenus.create({ id: 'verstak-capture-page', title: 'Send page to Verstak', contexts: ['page'] }); - ext.contextMenus.create({ id: 'verstak-capture-selection', title: 'Send selection to Verstak', contexts: ['selection'] }); - ext.contextMenus.create({ id: 'verstak-capture-link', title: 'Send link to Verstak', contexts: ['link'] }); + if (!ext.contextMenus) return Promise.resolve(); + return Promise.all([getSettings(), loadLocaleCatalogs()]).then(function (results) { + var locale = i18n.resolveLocale(results[0].language, browserLocale()); + var t = i18n.createTranslator(results[1], locale); + return new Promise(function (resolve) { + ext.contextMenus.removeAll(function () { + ext.contextMenus.create({ id: 'verstak-capture-page', title: t('context.sendPage', null, 'Send page to Verstak'), contexts: ['page'] }); + ext.contextMenus.create({ id: 'verstak-capture-selection', title: t('context.sendSelection', null, 'Send selection to Verstak'), contexts: ['selection'] }); + ext.contextMenus.create({ id: 'verstak-capture-link', title: t('context.sendLink', null, 'Send link to Verstak'), contexts: ['link'] }); + resolve(); + }); + }); }); } @@ -128,6 +164,8 @@ if (message.action === 'getState') return getState(); if (message.action === 'saveSettings') { return saveSettings(message.settings).then(function () { + return setupContextMenus(); + }).then(function () { return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); }).then(getState); }