Compare commits
9
Commits
aa57061769
...
v2.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8bd1d1828 | ||
|
|
619ff97b61 | ||
|
|
af44ce2fa2 | ||
|
|
604bb459c8 | ||
|
|
cae140a348 | ||
|
|
766458d0a0 | ||
|
|
18bff9f352 | ||
|
|
fb4b41d367 | ||
|
|
00831d3bf6 |
@@ -67,6 +67,15 @@ Headers:
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
|
||||
"author": "Verstak",
|
||||
"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.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
|
||||
"author": "Verstak",
|
||||
"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/*"],
|
||||
"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",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.3.0"
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"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 && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
|
||||
"sign:firefox": "./scripts/sign-firefox-xpi.sh",
|
||||
"release:firefox": "./scripts/release-firefox-xpi.sh"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
const chromiumDist = path.join(dist, 'chromium');
|
||||
@@ -47,18 +54,21 @@ concat([
|
||||
path.join(shared, 'protocol.js'),
|
||||
path.join(shared, 'api.js'),
|
||||
path.join(shared, 'queue.js'),
|
||||
path.join(shared, 'i18n.js'),
|
||||
path.join(shared, 'background.js'),
|
||||
], path.join(chromiumDist, 'background.js'));
|
||||
copyPopup(chromiumDist);
|
||||
copyIcons(chromiumDist);
|
||||
copyLocalization(chromiumDist);
|
||||
|
||||
const firefoxDist = path.join(dist, 'firefox');
|
||||
mkdir(firefoxDist);
|
||||
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));
|
||||
}
|
||||
copyPopup(firefoxDist);
|
||||
copyIcons(firefoxDist);
|
||||
copyLocalization(firefoxDist);
|
||||
|
||||
console.log('built dist/chromium and dist/firefox');
|
||||
|
||||
@@ -44,9 +44,9 @@ if [[ -z "$VERSION" || -z "$ADDON_ID" ]]; then
|
||||
exit 1
|
||||
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
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ echo "Using AMO API proxy from WEB_EXT_API_PROXY"
|
||||
|
||||
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
|
||||
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
|
||||
exit 1
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+56
-8
@@ -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,28 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
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 === 'saveSettings') {
|
||||
return saveSettings(message.settings).then(function () {
|
||||
return setupContextMenus();
|
||||
}).then(function () {
|
||||
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
|
||||
}).then(getState);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Отправить ссылку в Верстак"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+15
-8
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -17,15 +17,15 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<span>Receiver</span>
|
||||
<span id="receiver-label">Receiver</span>
|
||||
<strong id="receiver-state">Unknown</strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Pending</span>
|
||||
<span id="pending-label">Pending</span>
|
||||
<strong id="pending-count">0</strong>
|
||||
</div>
|
||||
<div class="row url-row">
|
||||
<span>URL</span>
|
||||
<span id="url-label">URL</span>
|
||||
<code id="receiver-url"></code>
|
||||
</div>
|
||||
</section>
|
||||
@@ -37,21 +37,28 @@
|
||||
</section>
|
||||
|
||||
<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">
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
<script src="../i18n.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+141
-57
@@ -2,16 +2,93 @@
|
||||
'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) {
|
||||
if (element.__verstakI18nFallback == null) element.__verstakI18nFallback = element.textContent;
|
||||
element.textContent = t(staticText[id], null, element.__verstakI18nFallback);
|
||||
}
|
||||
});
|
||||
applyReceiverState(currentState);
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
@@ -42,48 +119,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 +181,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 +208,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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user