diff --git a/package.json b/package.json index 2fde3ab..318fa93 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-popup-settings.js", + "test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js", "sign:firefox": "./scripts/sign-firefox-xpi.sh", "release:firefox": "./scripts/release-firefox-xpi.sh" }, diff --git a/scripts/test-i18n.js b/scripts/test-i18n.js new file mode 100644 index 0000000..6a7ade6 --- /dev/null +++ b/scripts/test-i18n.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const i18n = require(path.join(root, 'shared', 'i18n.js')); +const en = JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'en.json'), 'utf8')); +const ru = JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'ru.json'), 'utf8')); + +assert.strictEqual(i18n.normalizePreference(undefined), 'system'); +assert.strictEqual(i18n.normalizePreference('de'), 'system'); +assert.strictEqual(i18n.normalizePreference('system'), 'system'); +assert.strictEqual(i18n.normalizePreference('en'), 'en'); +assert.strictEqual(i18n.normalizePreference('ru'), 'ru'); + +assert.strictEqual(i18n.resolveLocale('system', 'ru'), 'ru'); +assert.strictEqual(i18n.resolveLocale('system', 'ru-RU'), 'ru'); +assert.strictEqual(i18n.resolveLocale('system', 'RU-ru'), 'ru'); +assert.strictEqual(i18n.resolveLocale('system', 'uk-UA'), 'en'); +assert.strictEqual(i18n.resolveLocale('system', 'en-US'), 'en'); +assert.strictEqual(i18n.resolveLocale('en', 'ru-RU'), 'en'); +assert.strictEqual(i18n.resolveLocale('ru', 'en-US'), 'ru'); + +assert.deepStrictEqual(Object.keys(ru).sort(), Object.keys(en).sort()); +assert.ok(Object.values(en).every((value) => typeof value === 'string')); +assert.ok(Object.values(ru).every((value) => typeof value === 'string')); + +const tEn = i18n.createTranslator({ en, ru }, 'en'); +const tRu = i18n.createTranslator({ en, ru }, 'ru'); +assert.strictEqual(tRu('status.queued'), 'В очереди до запуска Верстака'); +assert.strictEqual(tEn('error.value', { error: 'offline' }), 'Error: offline'); +assert.strictEqual(tRu('missing', null, 'Fallback'), 'Fallback'); +assert.strictEqual(tRu('missing.key'), 'missing.key'); + +i18n.loadCatalogs((locale) => Promise.resolve(locale === 'ru' ? ru : en)) + .then((catalogs) => { + assert.strictEqual(catalogs.en, en); + assert.strictEqual(catalogs.ru, ru); + console.log('browser extension localization runtime tests passed'); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/shared/i18n.js b/shared/i18n.js new file mode 100644 index 0000000..c8192bc --- /dev/null +++ b/shared/i18n.js @@ -0,0 +1,54 @@ +(function (root, factory) { + 'use strict'; + + var api = factory(); + root.VerstakBrowserI18n = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + var PREFERENCES = { system: true, en: true, ru: true }; + + function normalizePreference(value) { + value = String(value || '').trim().toLowerCase(); + return PREFERENCES[value] ? value : 'system'; + } + + function resolveLocale(preference, systemLocale) { + preference = normalizePreference(preference); + if (preference === 'en' || preference === 'ru') return preference; + systemLocale = String(systemLocale || '').trim().toLowerCase(); + return systemLocale === 'ru' || systemLocale.indexOf('ru-') === 0 ? 'ru' : 'en'; + } + + function interpolate(message, params) { + if (!params) return message; + return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, function (placeholder, name) { + return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder; + }); + } + + function loadCatalogs(loadJSON) { + return Promise.all([loadJSON('en'), loadJSON('ru')]).then(function (catalogs) { + return { en: catalogs[0] || {}, ru: catalogs[1] || {} }; + }); + } + + function createTranslator(catalogs, locale) { + catalogs = catalogs || {}; + locale = locale === 'ru' ? 'ru' : 'en'; + return function translate(key, params, fallback) { + var message = catalogs[locale] && catalogs[locale][key]; + if (message == null && catalogs.en) message = catalogs.en[key]; + if (message == null) message = fallback == null ? key : fallback; + return interpolate(String(message), params); + }; + } + + return { + normalizePreference: normalizePreference, + resolveLocale: resolveLocale, + loadCatalogs: loadCatalogs, + createTranslator: createTranslator + }; +}); diff --git a/shared/locales/en.json b/shared/locales/en.json new file mode 100644 index 0000000..5785e83 --- /dev/null +++ b/shared/locales/en.json @@ -0,0 +1,33 @@ +{ + "popup.subtitle": "Browser Inbox", + "label.receiver": "Receiver", + "label.pending": "Pending", + "label.url": "URL", + "label.file": "File", + "label.receiverUrl": "Receiver URL", + "label.pairingToken": "Pairing token", + "label.language": "Language", + "action.sendPage": "Send Page", + "action.sendFile": "Send File", + "action.retryPending": "Retry Pending", + "action.save": "Save", + "hint.contextMenu": "Selection and link captures are available from the page context menu.", + "receiver.online": "Online", + "receiver.offline": "Offline", + "receiver.unknown": "Unknown", + "language.system": "System", + "language.en": "English", + "language.ru": "Russian", + "status.sending": "Sending...", + "status.queued": "Queued until Verstak is available", + "status.done": "Done", + "status.readingFile": "Reading file...", + "status.saved": "Saved", + "error.chooseFile": "Choose a file first", + "error.fileTooLarge": "File is too large for browser capture", + "error.invalidReceiverUrl": "Receiver URL must start with http:// or https://", + "error.value": "Error: {error}", + "context.sendPage": "Send page to Verstak", + "context.sendSelection": "Send selection to Verstak", + "context.sendLink": "Send link to Verstak" +} diff --git a/shared/locales/ru.json b/shared/locales/ru.json new file mode 100644 index 0000000..059ef08 --- /dev/null +++ b/shared/locales/ru.json @@ -0,0 +1,33 @@ +{ + "popup.subtitle": "Входящие из браузера", + "label.receiver": "Приёмник", + "label.pending": "В очереди", + "label.url": "URL", + "label.file": "Файл", + "label.receiverUrl": "URL приёмника", + "label.pairingToken": "Токен сопряжения", + "label.language": "Язык", + "action.sendPage": "Отправить страницу", + "action.sendFile": "Отправить файл", + "action.retryPending": "Повторить отправку", + "action.save": "Сохранить", + "hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.", + "receiver.online": "Доступен", + "receiver.offline": "Недоступен", + "receiver.unknown": "Неизвестно", + "language.system": "Системный", + "language.en": "Английский", + "language.ru": "Русский", + "status.sending": "Отправка...", + "status.queued": "В очереди до запуска Верстака", + "status.done": "Готово", + "status.readingFile": "Чтение файла...", + "status.saved": "Сохранено", + "error.chooseFile": "Сначала выберите файл", + "error.fileTooLarge": "Файл слишком велик для отправки из браузера", + "error.invalidReceiverUrl": "URL приёмника должен начинаться с http:// или https://", + "error.value": "Ошибка: {error}", + "context.sendPage": "Отправить страницу в Верстак", + "context.sendSelection": "Отправить выделение в Верстак", + "context.sendLink": "Отправить ссылку в Верстак" +}