9 Commits
21 changed files with 1117 additions and 87 deletions
+9
View File
@@ -67,6 +67,15 @@ Headers:
Rotating the token in Desktop invalidates the value stored by the extension. Rotating the token in Desktop invalidates the value stored by the extension.
## Language
The popup settings provide a persisted `System / English / Russian` language
selector. `System` follows the browser UI language: Russian browser locales use
Russian, and all other locales use English.
The extension and desktop application store their language choices
independently. Changing one does not change the other.
Payload: Payload:
```json ```json
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.2",
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
@@ -0,0 +1,328 @@
# Browser Extension Localization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add persisted `System / English / Russian` language selection to the browser extension, localizing popup chrome and context menus in Chromium and Firefox.
**Architecture:** A framework-independent `shared/i18n.js` resolves preferences and loads extension-owned JSON catalogs. Popup and background both consume it; background owns persistence and context-menu recreation, while popup performs immediate DOM translation and sends settings updates.
**Tech Stack:** WebExtension APIs, plain ES5-compatible JavaScript, JSON catalogs, Node `assert`/`vm` tests, existing Node build script.
## Global Constraints
- Store only `system`, `en`, or `ru`; missing/invalid values normalize to `system`.
- `ru` and `ru-*` browser UI locales resolve to Russian; all other system locales resolve to English.
- Keep the extension language independent from desktop settings.
- Add no runtime dependency and no manifest-localization scope.
- Never translate receiver URLs, tokens, file names, user content, or raw receiver error text.
- Preserve receiver URL/token values when language changes.
- Both browser distributions must contain `i18n.js`, `locales/en.json`, and `locales/ru.json`.
---
### Task 1: Shared Locale Runtime and Catalog Contract
**Files:**
- Create: `shared/i18n.js`
- Create: `shared/locales/en.json`
- Create: `shared/locales/ru.json`
- Create: `scripts/test-i18n.js`
- Modify: `package.json`
**Interfaces:**
- Produces: `globalThis.VerstakBrowserI18n.normalizePreference(value)`.
- Produces: `globalThis.VerstakBrowserI18n.resolveLocale(preference, systemLocale)`.
- Produces: `globalThis.VerstakBrowserI18n.loadCatalogs(loadJSON)` returning `Promise<{en,ru}>`.
- Produces: `globalThis.VerstakBrowserI18n.createTranslator(catalogs, locale)` returning `t(key, params, fallback)`.
- [ ] **Step 1: Write the failing runtime/catalog test**
Create `scripts/test-i18n.js` that loads `shared/i18n.js` in `vm`, reads both JSON catalogs, and asserts:
```js
assert.strictEqual(i18n.normalizePreference('de'), 'system');
assert.strictEqual(i18n.resolveLocale('system', 'ru-RU'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'uk-UA'), 'en');
assert.strictEqual(i18n.resolveLocale('en', 'ru-RU'), 'en');
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'));
assert.strictEqual(i18n.createTranslator({ en, ru }, 'ru')('status.queued'), 'В очереди до запуска Верстака');
assert.strictEqual(i18n.createTranslator({ en, ru }, 'en')('error.value', { error: 'offline' }), 'Error: offline');
```
- [ ] **Step 2: Run the test and confirm RED**
Run: `node scripts/test-i18n.js`
Expected: FAIL because `shared/i18n.js` and catalogs do not exist.
- [ ] **Step 3: Implement the minimal shared runtime**
Implement an IIFE exporting the four functions. `loadCatalogs(loadJSON)` must call `loadJSON('en')` and `loadJSON('ru')`; translation lookup order is selected catalog, English catalog, explicit fallback, key. Interpolation replaces `{name}` only when `params` owns that property.
Catalogs must define identical keys for:
```text
popup.subtitle
label.receiver
label.pending
label.url
label.file
label.receiverUrl
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
error.value
context.sendPage
context.sendSelection
context.sendLink
```
- [ ] **Step 4: Add the test to `npm test` and verify GREEN**
Set:
```json
"test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js"
```
Run: `node scripts/test-i18n.js`
Expected: `browser extension localization runtime tests passed`.
- [ ] **Step 5: Commit the shared contract**
```bash
git add shared/i18n.js shared/locales scripts/test-i18n.js package.json
git commit -m "feat: add browser extension localization runtime"
```
---
### Task 2: Persisted Language and Localized Context Menus
**Files:**
- Modify: `shared/background.js`
- Modify: `chromium/manifest.json`
- Modify: `firefox/manifest.json`
- Create: `scripts/test-background-i18n.js`
**Interfaces:**
- Consumes: `VerstakBrowserI18n.loadCatalogs`, `normalizePreference`, `resolveLocale`, `createTranslator`.
- Produces: persisted `settings.language` and context-menu recreation after `saveSettings`.
- [ ] **Step 1: Write the failing background test**
Load `protocol.js`, `queue.js`, `i18n.js`, and `background.js` into `vm` with fake storage/context-menu APIs. Seed `settings.language = 'ru'`, trigger `runtime.onInstalled`, and assert:
```js
assert.deepStrictEqual(menuTitles, [
'Отправить страницу в Верстак',
'Отправить выделение в Верстак',
'Отправить ссылку в Верстак',
]);
```
Send a `saveSettings` message with `{ receiverUrl, receiverToken, language: 'en' }`, then assert stored receiver fields are unchanged and menu titles become English.
- [ ] **Step 2: Run the test and confirm RED**
Run: `node scripts/test-background-i18n.js`
Expected: FAIL because background defaults contain no language and menu titles are literal English.
- [ ] **Step 3: Implement background localization**
Add `language: 'system'` to `DEFAULT_SETTINGS`. Normalize it in `getSettings`/`saveSettings`. Load catalogs with:
```js
function loadLocaleCatalogs() {
return 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();
});
});
}
```
Make `setupContextMenus()` resolve settings plus catalogs, choose
`ext.i18n.getUILanguage()` with English fallback, and create the three menu
titles via `t('context.sendPage')`, `t('context.sendSelection')`, and
`t('context.sendLink')`. Call it after language settings are saved.
- [ ] **Step 4: Load `i18n.js` before background code in both manifests**
- Chromium service worker remains bundled by the build concatenation order.
- Firefox `background.scripts` becomes `protocol.js`, `api.js`, `queue.js`, `i18n.js`, `background.js`.
Also append `node scripts/test-background-i18n.js` to the `test` script in
`package.json` now that the test file exists.
- [ ] **Step 5: Verify and commit**
Run: `node scripts/test-background-i18n.js`
Expected: `browser extension background localization tests passed`.
```bash
git add shared/background.js chromium/manifest.json firefox/manifest.json scripts/test-background-i18n.js
git commit -m "feat: localize browser extension context menus"
```
---
### Task 3: Popup Language Selector and Live Translation
**Files:**
- Modify: `shared/popup/popup.html`
- Modify: `shared/popup/popup.js`
- Modify: `shared/popup/popup.css`
- Modify: `scripts/test-popup-settings.js`
**Interfaces:**
- Consumes: state `settings.language` from background and the shared translator.
- Produces: immediate popup translation plus a `saveSettings` message containing receiver URL, token, and language.
- [ ] **Step 1: Extend the popup test and confirm RED**
Add fake elements for every localized ID plus `language-select`, mock catalog fetches, `browser.i18n.getUILanguage()`, and `document.documentElement.lang`. Assert initial `system` with `ru-RU` renders Russian. Change select value to `en`, dispatch `change`, flush promises, then assert:
```js
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
```
- [ ] **Step 2: Add popup markup**
Load `../i18n.js` before `popup.js`, give all static user-visible elements stable IDs, and add:
```html
<label id="language-label" for="language-select">Language</label>
<select id="language-select">
<option value="system">System</option>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
```
- [ ] **Step 3: Implement asynchronous popup initialization and live switch**
Load both catalogs through `ext.runtime.getURL`, resolve the locale from saved
preference plus browser UI language, and apply all static text through one
`applyLocale(preference)` function. `render(state)` localizes
online/offline/unknown. Known statuses and validations use catalog keys; raw
error messages are wrapped only when needed by `error.value`.
The select `change` handler must call `applyLocale` before awaiting background
storage, then send all three settings fields. The existing Save button also
sends the selected language.
- [ ] **Step 4: Style the select consistently and verify GREEN**
Share the existing input box model/colors with `select` and keep the popup
minimum width unchanged.
Run: `node scripts/test-popup-settings.js`
Expected: `browser extension popup localization/settings tests passed`.
- [ ] **Step 5: Commit**
```bash
git add shared/popup scripts/test-popup-settings.js
git commit -m "feat: add browser extension language selector"
```
---
### Task 4: Distribution Packaging, Documentation, and Full Verification
**Files:**
- Modify: `scripts/build-extension.js`
- Modify: `README.md`
- Test: `scripts/test-i18n.js`
- Test: `scripts/test-background-i18n.js`
- Test: `scripts/test-popup-settings.js`
**Interfaces:**
- Consumes: shared runtime/catalogs and updated manifests.
- Produces: complete `dist/chromium` and `dist/firefox` extension directories.
- [ ] **Step 1: Add failing build-content assertions**
Extend `scripts/test-i18n.js` to run after build when `dist` exists and assert
both targets contain:
```text
i18n.js
locales/en.json
locales/ru.json
```
- [ ] **Step 2: Update the build**
For Chromium, concatenate `shared/i18n.js` before `shared/background.js` and
also copy it as `dist/chromium/i18n.js` for popup use. For Firefox, copy
`shared/i18n.js` beside background scripts. Copy both JSON catalogs to each
target's `locales/` directory.
- [ ] **Step 3: Document the selector**
Add a README section stating that popup settings provide persisted
`System / English / Russian`, that System uses browser UI language, and that
extension/desktop preferences are independent.
- [ ] **Step 4: Run complete verification**
```bash
npm test
npm run build
npm test
git diff --check
```
Expected:
- protocol, runtime, popup, and background tests pass;
- both browser distributions build;
- post-build content assertions pass;
- no whitespace errors.
- [ ] **Step 5: Commit packaging and docs**
```bash
git add scripts/build-extension.js README.md scripts/test-i18n.js
git commit -m "build: package browser extension locale catalogs"
```
@@ -0,0 +1,114 @@
# Browser Extension Localization Design
## Goal
Add English and Russian localization to the Verstak browser extension with a
persisted manual `System / English / Russian` language selector. The extension
remains independent from the desktop application's language preference.
## Scope
The change covers:
- all user-visible popup labels, hints, statuses, validation messages, and
receiver-state labels;
- the three browser context-menu titles;
- persisted language selection;
- Chromium and Firefox build packaging;
- automated tests for locale resolution, persistence, live popup updates, and
context-menu localization.
Manifest name and description localization is not part of this milestone. The
existing English manifest values remain the fallback in both browser builds.
## Locale Model
The stored setting is named `language` and accepts only:
- `system` (default);
- `en`;
- `ru`.
`system` resolves the browser UI language through
`browser.i18n.getUILanguage()` / `chrome.i18n.getUILanguage()`. Popup code may
fall back to `navigator.language` when the extension i18n API is unavailable.
`ru` and `ru-*` resolve to Russian; every other value resolves to English.
Invalid or missing stored preferences normalize to `system`.
## Catalogs and Runtime
The extension owns framework-independent JSON catalogs:
```text
shared/locales/en.json
shared/locales/ru.json
```
A small shared runtime provides:
- preference normalization;
- system-locale resolution;
- key lookup with English and literal fallback;
- `{parameter}` interpolation.
The runtime has no dependency on desktop, the plugin SDK, DOM APIs, or external
packages. It is loaded by both popup and background scripts.
Both catalogs must contain identical string-only keys. The build copies them
into `dist/chromium/locales` and `dist/firefox/locales` together with the shared
runtime.
## Popup Behavior
The existing settings section gains a labeled language `<select>` with
`system`, `en`, and `ru` options. Selecting a value:
1. updates all popup chrome immediately without reopening the popup;
2. sends the full settings patch to background storage;
3. preserves receiver URL and pairing token values;
4. shows the localized saved/error state.
The `<html lang>` attribute is updated to the resolved locale. User data,
receiver URLs, tokens, file names, and backend-provided error text are not
translated.
## Background and Context Menus
`DEFAULT_SETTINGS` gains `language: "system"`. Context-menu creation resolves
the effective locale from stored settings and creates localized page,
selection, and link titles.
After a language setting is saved, background recreates the context menus so
the new language applies without reinstalling or reloading the extension.
Failure to access the i18n API falls back to English and must not block capture
or settings operations.
## Error Handling
- Missing catalog keys fall back to English, then to the provided literal/key.
- Invalid stored language values behave as `system`.
- Catalog or browser-language detection failure falls back to English.
- Existing receiver and capture errors continue to be shown; known extension
validation/status chrome around them is localized.
## Verification
Tests are written before production changes and cover:
- `ru-*` and non-Russian system-language resolution;
- explicit language override and invalid-preference normalization;
- English/Russian catalog parity and interpolation;
- popup language persistence without losing receiver settings;
- live popup translation after selecting Russian/English;
- localized online/offline/unknown and capture status messages;
- localized context-menu creation and recreation after language changes;
- build contents for both Chromium and Firefox.
Final verification commands:
```bash
npm test
npm run build
```
Manual installation or store signing is outside this milestone.
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.2",
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
@@ -17,7 +17,7 @@
}, },
"permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], "permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"scripts": ["protocol.js", "api.js", "queue.js", "background.js"] "scripts": ["protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
}, },
"browser_action": { "browser_action": {
"default_popup": "popup/popup.html", "default_popup": "popup/popup.html",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.2",
"devDependencies": { "devDependencies": {
"web-ext": "^8.3.0" "web-ext": "^8.3.0"
} }
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.2",
"private": true, "private": true,
"description": "Verstak browser capture extension for Chromium and Firefox", "description": "Verstak browser capture extension for Chromium and Firefox",
"scripts": { "scripts": {
"build": "node scripts/build-extension.js", "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 && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
"sign:firefox": "./scripts/sign-firefox-xpi.sh", "sign:firefox": "./scripts/sign-firefox-xpi.sh",
"release:firefox": "./scripts/release-firefox-xpi.sh" "release:firefox": "./scripts/release-firefox-xpi.sh"
}, },
+11 -1
View File
@@ -38,6 +38,13 @@ function copyIcons(destRoot) {
} }
} }
function copyLocalization(destRoot) {
copy(path.join(shared, 'i18n.js'), path.join(destRoot, 'i18n.js'));
for (const locale of ['en', 'ru']) {
copy(path.join(shared, 'locales', `${locale}.json`), path.join(destRoot, 'locales', `${locale}.json`));
}
}
rm(dist); rm(dist);
const chromiumDist = path.join(dist, 'chromium'); const chromiumDist = path.join(dist, 'chromium');
@@ -47,18 +54,21 @@ concat([
path.join(shared, 'protocol.js'), path.join(shared, 'protocol.js'),
path.join(shared, 'api.js'), path.join(shared, 'api.js'),
path.join(shared, 'queue.js'), path.join(shared, 'queue.js'),
path.join(shared, 'i18n.js'),
path.join(shared, 'background.js'), path.join(shared, 'background.js'),
], path.join(chromiumDist, 'background.js')); ], path.join(chromiumDist, 'background.js'));
copyPopup(chromiumDist); copyPopup(chromiumDist);
copyIcons(chromiumDist); copyIcons(chromiumDist);
copyLocalization(chromiumDist);
const firefoxDist = path.join(dist, 'firefox'); const firefoxDist = path.join(dist, 'firefox');
mkdir(firefoxDist); mkdir(firefoxDist);
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json')); copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
for (const name of ['protocol.js', 'api.js', 'queue.js', 'background.js']) { for (const name of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
copy(path.join(shared, name), path.join(firefoxDist, name)); copy(path.join(shared, name), path.join(firefoxDist, name));
} }
copyPopup(firefoxDist); copyPopup(firefoxDist);
copyIcons(firefoxDist); copyIcons(firefoxDist);
copyLocalization(firefoxDist);
console.log('built dist/chromium and dist/firefox'); console.log('built dist/chromium and dist/firefox');
+2 -2
View File
@@ -44,9 +44,9 @@ if [[ -z "$VERSION" || -z "$ADDON_ID" ]]; then
exit 1 exit 1
fi fi
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)" SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name "*-${VERSION}.xpi" | sort | tail -n 1 || true)"
if [[ -z "$SIGNED_XPI" ]]; then if [[ -z "$SIGNED_XPI" ]]; then
echo "ERROR: no signed XPI found in $ARTIFACTS_DIR" >&2 echo "ERROR: no signed XPI for version $VERSION found in $ARTIFACTS_DIR" >&2
exit 1 exit 1
fi fi
+1 -1
View File
@@ -84,7 +84,7 @@ echo "Using AMO API proxy from WEB_EXT_API_PROXY"
npx web-ext "${SIGN_ARGS[@]}" npx web-ext "${SIGN_ARGS[@]}"
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)" SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' -printf '%T@ %p\n' | sort -n | tail -n 1 | cut -d' ' -f2- || true)"
if [[ -z "$SIGNED_XPI" ]]; then if [[ -z "$SIGNED_XPI" ]]; then
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2 echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
exit 1 exit 1
+119
View File
@@ -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() {
menuTitles = [];
return Promise.resolve();
},
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);
});
+57
View File
@@ -0,0 +1,57 @@
#!/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');
const dist = path.join(root, 'dist');
if (fs.existsSync(dist)) {
for (const target of ['chromium', 'firefox']) {
for (const relativePath of ['i18n.js', 'locales/en.json', 'locales/ru.json']) {
assert.ok(
fs.existsSync(path.join(dist, target, relativePath)),
`${target} build is missing ${relativePath}`,
);
}
}
}
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);
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class Element {
constructor(text) {
this.value = '';
this.textContent = text || '';
this.className = '';
this.files = [];
this.listeners = {};
}
addEventListener(type, listener) { this.listeners[type] = listener; }
}
const ids = [
'status', 'receiver-state', 'receiver-url', 'receiver-input', 'receiver-token-input',
'language-select', 'file-input', 'pending-count', 'status-dot', 'subtitle',
'receiver-label', 'pending-label', 'url-label', 'file-label', 'receiver-url-label',
'receiver-token-label', 'language-label', 'capture-page', 'capture-file', 'retry',
'save-settings', 'context-menu-hint', 'language-system-option', 'language-en-option',
'language-ru-option',
];
const elements = Object.fromEntries(ids.map((id) => [id, new Element()]));
elements['capture-page'].textContent = 'Send Page';
const browser = {
runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
sendMessage() {
return Promise.resolve({
settings: { receiverUrl: 'http://127.0.0.1/', receiverToken: '', language: 'system' },
pendingCount: 0,
status: {},
});
},
},
i18n: { getUILanguage() { return 'en-US'; } },
};
const document = {
activeElement: null,
documentElement: { lang: '' },
getElementById(id) { return elements[id]; },
};
const context = vm.createContext({
browser,
document,
navigator: { language: 'en-US' },
console: { warn() {}, error: console.error },
Promise,
fetch() { return Promise.reject(new Error('catalog unavailable')); },
});
context.globalThis = context;
const root = path.resolve(__dirname, '..');
for (const file of ['shared/i18n.js', 'shared/popup/popup.js']) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file });
}
(async () => {
for (let i = 0; i < 16; i += 1) await Promise.resolve();
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
console.log('browser extension popup catalog fallback tests passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+55 -3
View File
@@ -20,6 +20,10 @@ class Element {
click() { click() {
if (this.listeners.click) this.listeners.click(); if (this.listeners.click) this.listeners.click();
} }
change() {
if (this.listeners.change) this.listeners.change({ target: this });
}
} }
const elements = {}; const elements = {};
@@ -32,10 +36,23 @@ const elements = {};
'file-input', 'file-input',
'pending-count', 'pending-count',
'status-dot', 'status-dot',
'subtitle',
'receiver-label',
'pending-label',
'url-label',
'capture-page', 'capture-page',
'capture-file', 'capture-file',
'retry', 'retry',
'file-label',
'receiver-url-label',
'receiver-token-label',
'language-label',
'language-select',
'language-system-option',
'language-en-option',
'language-ru-option',
'save-settings', 'save-settings',
'context-menu-hint',
].forEach((id) => { ].forEach((id) => {
elements[id] = new Element(); elements[id] = new Element();
}); });
@@ -45,12 +62,14 @@ const initialState = {
settings: { settings: {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'persisted-token', receiverToken: 'persisted-token',
language: 'system',
}, },
pendingCount: 0, pendingCount: 0,
status: {}, status: {},
}; };
const browser = { const browser = {
runtime: { runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
sendMessage(message) { sendMessage(message) {
if (message.action === 'getState') return Promise.resolve(initialState); if (message.action === 'getState') return Promise.resolve(initialState);
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
@@ -60,24 +79,56 @@ const browser = {
return Promise.resolve(initialState); return Promise.resolve(initialState);
}, },
}, },
i18n: { getUILanguage() { return 'ru-RU'; } },
}; };
const document = { const document = {
activeElement: null, activeElement: null,
documentElement: { lang: '' },
getElementById(id) { getElementById(id) {
return elements[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'); 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() { 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 () => { (async () => {
await flush(); await flush();
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl); assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken); 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-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
elements['receiver-token-input'].value = 'new-token'; elements['receiver-token-input'].value = 'new-token';
@@ -87,7 +138,8 @@ async function flush() {
assert.ok(savedSettings); assert.ok(savedSettings);
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
assert.strictEqual(savedSettings.receiverToken, 'new-token'); 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) => { })().catch((error) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
+56 -8
View File
@@ -4,20 +4,49 @@
var ext = typeof browser !== 'undefined' ? browser : chrome; var ext = typeof browser !== 'undefined' ? browser : chrome;
var protocol = globalThis.VerstakBrowser; var protocol = globalThis.VerstakBrowser;
var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext)); var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext));
var i18n = globalThis.VerstakBrowserI18n;
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverUrl: protocol.DEFAULT_RECEIVER_URL,
receiverToken: '' receiverToken: '',
language: 'system'
}; };
var STATUS_KEY = 'verstak.status'; var STATUS_KEY = 'verstak.status';
var localeCatalogs = null;
function getSettings() { function getSettings() {
return ext.storage.local.get('settings').then(function (result) { 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) { 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) { function setStatus(patch) {
@@ -105,11 +134,28 @@
} }
function setupContextMenus() { function setupContextMenus() {
if (!ext.contextMenus) return; if (!ext.contextMenus) return Promise.resolve();
ext.contextMenus.removeAll(function () { return Promise.all([getSettings(), loadLocaleCatalogs()]).then(function (results) {
ext.contextMenus.create({ id: 'verstak-capture-page', title: 'Send page to Verstak', contexts: ['page'] }); var locale = i18n.resolveLocale(results[0].language, browserLocale());
ext.contextMenus.create({ id: 'verstak-capture-selection', title: 'Send selection to Verstak', contexts: ['selection'] }); var t = i18n.createTranslator(results[1], locale);
ext.contextMenus.create({ id: 'verstak-capture-link', title: 'Send link to Verstak', contexts: ['link'] }); return new Promise(function (resolve) {
var created = false;
function createMenus() {
if (created) return;
created = true;
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();
}
var removal;
try {
removal = ext.contextMenus.removeAll(createMenus);
} catch (_) {
removal = ext.contextMenus.removeAll();
}
if (removal && typeof removal.then === 'function') removal.then(createMenus, createMenus);
});
}); });
} }
@@ -128,6 +174,8 @@
if (message.action === 'getState') return getState(); if (message.action === 'getState') return getState();
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
return saveSettings(message.settings).then(function () { return saveSettings(message.settings).then(function () {
return setupContextMenus();
}).then(function () {
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
}).then(getState); }).then(getState);
} }
+54
View File
@@ -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
};
});
+33
View File
@@ -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"
}
+33
View File
@@ -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": "Отправить ссылку в Верстак"
}
+11
View File
@@ -126,6 +126,17 @@ input {
color: #e5e7eb; 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"] { input[type="file"] {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
+15 -8
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,15 +17,15 @@
<section class="panel"> <section class="panel">
<div class="row"> <div class="row">
<span>Receiver</span> <span id="receiver-label">Receiver</span>
<strong id="receiver-state">Unknown</strong> <strong id="receiver-state">Unknown</strong>
</div> </div>
<div class="row"> <div class="row">
<span>Pending</span> <span id="pending-label">Pending</span>
<strong id="pending-count">0</strong> <strong id="pending-count">0</strong>
</div> </div>
<div class="row url-row"> <div class="row url-row">
<span>URL</span> <span id="url-label">URL</span>
<code id="receiver-url"></code> <code id="receiver-url"></code>
</div> </div>
</section> </section>
@@ -37,21 +37,28 @@
</section> </section>
<section class="file-picker"> <section class="file-picker">
<label for="file-input">File</label> <label id="file-label" for="file-input">File</label>
<input id="file-input" type="file"> <input id="file-input" type="file">
</section> </section>
<section class="settings"> <section class="settings">
<label for="receiver-input">Receiver URL</label> <label id="receiver-url-label" for="receiver-input">Receiver URL</label>
<input id="receiver-input" type="url" spellcheck="false"> <input id="receiver-input" type="url" spellcheck="false">
<label for="receiver-token-input">Pairing token</label> <label id="receiver-token-label" for="receiver-token-input">Pairing token</label>
<input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off"> <input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off">
<label id="language-label" for="language-select">Language</label>
<select id="language-select">
<option id="language-system-option" value="system">System</option>
<option id="language-en-option" value="en">English</option>
<option id="language-ru-option" value="ru">Русский</option>
</select>
<button id="save-settings" class="secondary">Save</button> <button id="save-settings" class="secondary">Save</button>
</section> </section>
<p class="hint">Selection and link captures are available from the page context menu.</p> <p id="context-menu-hint" class="hint">Selection and link captures are available from the page context menu.</p>
<p id="status"></p> <p id="status"></p>
</main> </main>
<script src="../i18n.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
+141 -57
View File
@@ -2,16 +2,93 @@
'use strict'; 'use strict';
var ext = typeof browser !== 'undefined' ? browser : chrome; var ext = typeof browser !== 'undefined' ? browser : chrome;
var i18n = globalThis.VerstakBrowserI18n;
var statusEl = document.getElementById('status'); var statusEl = document.getElementById('status');
var receiverStateEl = document.getElementById('receiver-state'); var receiverStateEl = document.getElementById('receiver-state');
var receiverUrlEl = document.getElementById('receiver-url'); var receiverUrlEl = document.getElementById('receiver-url');
var receiverInputEl = document.getElementById('receiver-input'); var receiverInputEl = document.getElementById('receiver-input');
var receiverTokenInputEl = document.getElementById('receiver-token-input'); var receiverTokenInputEl = document.getElementById('receiver-token-input');
var languageSelectEl = document.getElementById('language-select');
var fileInputEl = document.getElementById('file-input'); var fileInputEl = document.getElementById('file-input');
var pendingCountEl = document.getElementById('pending-count'); var pendingCountEl = document.getElementById('pending-count');
var statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 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) {
if (element.__verstakI18nFallback == null) element.__verstakI18nFallback = element.textContent;
element.textContent = t(staticText[id], null, element.__verstakI18nFallback);
}
});
applyReceiverState(currentState);
}
function arrayBufferToBase64(buffer) { function arrayBufferToBase64(buffer) {
var bytes = new Uint8Array(buffer); var bytes = new Uint8Array(buffer);
@@ -42,48 +119,58 @@
} }
function render(state) { function render(state) {
state = state || {}; currentState = state || {};
var settings = state.settings || {}; var settings = currentState.settings || {};
var status = state.status || {}; pendingCountEl.textContent = String(currentState.pendingCount || 0);
var reachable = status.receiverReachable;
pendingCountEl.textContent = String(state.pendingCount || 0);
receiverUrlEl.textContent = settings.receiverUrl || ''; receiverUrlEl.textContent = settings.receiverUrl || '';
if (document.activeElement !== receiverInputEl) { if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || '';
receiverInputEl.value = settings.receiverUrl || ''; if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || '';
} applyReceiverState(currentState);
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';
}
} }
function refresh() { function refresh() {
return request({ type: 'verstak.capture', action: 'getState' }).then(render).catch(function (err) { return request({ type: 'verstak.capture', action: 'getState' }).then(function (state) {
setStatus(err && err.message ? err.message : String(err)); 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) { function send(message) {
setStatus('Sending...'); setStatus(t('status.sending', null, 'Sending...'));
request(message).then(function (state) { request(message).then(function (state) {
render(state); render(state);
if (state.status && state.status.lastResult === 'queued') setStatus('Queued until Verstak is available'); if (state.status && state.status.lastResult === 'queued') {
else setStatus('Done'); setStatus(t('status.queued', null, 'Queued until Verstak is available'));
}).catch(function (err) { } else {
setStatus(err && err.message ? err.message : String(err)); setStatus(t('status.done', null, 'Done'));
}
}).catch(function (error) {
setStatus(error && error.message ? error.message : String(error));
}); });
} }
@@ -94,27 +181,26 @@
document.getElementById('capture-file').addEventListener('click', function () { document.getElementById('capture-file').addEventListener('click', function () {
var file = fileInputEl.files && fileInputEl.files[0]; var file = fileInputEl.files && fileInputEl.files[0];
if (!file) { if (!file) {
setStatus('Choose a text file first'); setStatus(t('error.chooseFile', null, 'Choose a file first'));
return; return;
} }
if (file.size > MAX_FILE_BYTES) { 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; return;
} }
setStatus('Reading file...'); setStatus(t('status.readingFile', null, 'Reading file...'));
Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) { Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) {
var content = results[1] || '';
send({ send({
type: 'verstak.capture', type: 'verstak.capture',
kind: 'file', kind: 'file',
fileName: file.name, fileName: file.name,
fileMime: file.type || '', fileMime: file.type || '',
fileSize: file.size, fileSize: file.size,
fileText: content, fileText: results[1] || '',
fileDataBase64: arrayBufferToBase64(results[0]) fileDataBase64: arrayBufferToBase64(results[0])
}); });
}).catch(function (err) { }).catch(function (error) {
setStatus(err && err.message ? err.message : String(err)); setStatus(error && error.message ? error.message : String(error));
}); });
}); });
@@ -122,24 +208,22 @@
send({ type: 'verstak.capture', action: 'retryPending' }); send({ type: 'verstak.capture', action: 'retryPending' });
}); });
document.getElementById('save-settings').addEventListener('click', function () { languageSelectEl.addEventListener('change', function () {
var receiverUrl = receiverInputEl.value.trim(); applyLocale(languageSelectEl.value);
var receiverToken = receiverTokenInputEl.value.trim(); saveCurrentSettings(true);
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));
});
}); });
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();
});
})(); })();