forked from akai/readest
feat(send): browser extension that clips pages into Readest as EPUBs (#4266)
Replaces the URL-only placeholder extension with a full MV3 page-clipper
that builds a self-contained EPUB on the user's machine and uploads it
to the inbox.
- Captures the rendered DOM in a content script, then runs Readability,
asset bundling, and EPUB build through the shared
`convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen
document (the SW lacks DOMParser).
- Uploads the resulting EPUB directly from the offscreen page to the
new `POST /api/send/inbox/file` endpoint — keeps the bytes in one
realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to
`{}` between extension contexts.
- Adds a long-lived Port + ping handshake between SW, offscreen, and
the on-demand capture content script so neither idle-eviction nor
load-order races can hang the popup.
- Localised popup, badge feedback, key-as-content i18n (`_('English source')`)
with an extract script that seeds locale stubs from i18n-langs.json
and writes a static-imports map for the runtime. All 33 locales
fully translated.
- Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body
(Content-Type: application/epub+zip), enforces the inbox pending cap,
stores to the existing send-inbox R2 bucket as `kind='file'`.
`assetBundler` now sets `credentials: 'include'` in the non-Tauri
branch so the extension SW carries paywalled-CDN cookies.
- 47 vitest cases for the extension shell (upload, badge, auth, lazy,
popup state machine, auth-bridge token sync) + 8 cases for the new
server endpoint. CI's `test_web_app` invokes both via the extended
`test:pr:web` plus a `build-browser-ext` step that catches webpack
alias / Tauri-stub regressions.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
# Send to Readest — browser extension
|
||||
|
||||
One-click capture of the current web page into your Readest library.
|
||||
|
||||
## How it works
|
||||
|
||||
1. A content script on `web.readest.com` copies the signed-in Supabase access
|
||||
token into the extension's local storage (no credentials are stored by the
|
||||
extension itself).
|
||||
2. The popup's **Send this page** button POSTs the active tab's URL to
|
||||
`POST /api/send/inbox` with that token.
|
||||
3. The URL lands in the user's `send_inbox`; the next Readest client to sync
|
||||
drains it — fetching the article, converting it to EPUB, and adding it to
|
||||
the library.
|
||||
|
||||
## Load it for development
|
||||
|
||||
1. Open `chrome://extensions`, enable **Developer mode**.
|
||||
2. **Load unpacked** → select this `send-to-readest/` directory.
|
||||
3. Sign in at <https://web.readest.com> once so the token is captured.
|
||||
4. Click the extension icon on any page.
|
||||
|
||||
## Before publishing to the Chrome Web Store
|
||||
|
||||
- Add `icons` (16/32/48/128 px) to `manifest.json`.
|
||||
- Replace the localStorage token bridge with a proper OAuth flow
|
||||
(`chrome.identity.launchWebAuthFlow`) if the Supabase token format changes.
|
||||
- Submit through the Chrome Web Store Developer Dashboard (review required).
|
||||
@@ -1,24 +0,0 @@
|
||||
// Runs on web.readest.com. Readest stores its Supabase session in
|
||||
// localStorage; copy the access token into extension storage so the popup can
|
||||
// authenticate to /api/send/inbox without the extension holding credentials.
|
||||
(function () {
|
||||
function findAccessToken() {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && /^sb-.*-auth-token$/.test(key)) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key));
|
||||
if (value && value.access_token) return value.access_token;
|
||||
} catch {
|
||||
/* ignore malformed entries */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = findAccessToken();
|
||||
if (token) {
|
||||
chrome.storage.local.set({ readestAccessToken: token, readestTokenAt: Date.now() });
|
||||
}
|
||||
})();
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Send to Readest",
|
||||
"version": "0.1.0",
|
||||
"description": "Send the current web page to your Readest library.",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"host_permissions": ["https://web.readest.com/*"],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Send to Readest"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://web.readest.com/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body {
|
||||
width: 280px;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
#url {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #1a1a1a;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
#status {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
min-height: 16px;
|
||||
}
|
||||
.err {
|
||||
color: #c0392b;
|
||||
}
|
||||
.ok {
|
||||
color: #1e8e3e;
|
||||
}
|
||||
a {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Send to Readest</h1>
|
||||
<p id="url">Loading…</p>
|
||||
<button id="send" disabled>Send this page</button>
|
||||
<p id="status"></p>
|
||||
<script src="popup.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
// Send to Readest — popup. Posts the current tab to /api/send/inbox; the
|
||||
// captured URL lands in the user's inbox and a Readest client converts it to
|
||||
// EPUB on next sync.
|
||||
const INBOX_ENDPOINT = 'https://web.readest.com/api/send/inbox';
|
||||
const LOGIN_URL = 'https://web.readest.com/';
|
||||
|
||||
const urlEl = document.getElementById('url');
|
||||
const sendEl = document.getElementById('send');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
let currentTab = null;
|
||||
|
||||
function setStatus(message, kind) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = kind || '';
|
||||
}
|
||||
|
||||
async function getActiveTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const { readestAccessToken } = await chrome.storage.local.get('readestAccessToken');
|
||||
return readestAccessToken || null;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
currentTab = await getActiveTab();
|
||||
if (!currentTab || !/^https?:/i.test(currentTab.url || '')) {
|
||||
urlEl.textContent = 'This page cannot be sent.';
|
||||
return;
|
||||
}
|
||||
urlEl.textContent = currentTab.url;
|
||||
sendEl.disabled = false;
|
||||
}
|
||||
|
||||
sendEl.addEventListener('click', async () => {
|
||||
sendEl.disabled = true;
|
||||
setStatus('Sending…');
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
setStatus('Sign in to Readest first.', 'err');
|
||||
chrome.tabs.create({ url: LOGIN_URL });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(INBOX_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url: currentTab.url, title: currentTab.title }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus('Sent — it will appear in your library shortly.', 'ok');
|
||||
} else if (res.status === 403) {
|
||||
setStatus('Session expired. Open Readest to sign in again.', 'err');
|
||||
chrome.tabs.create({ url: LOGIN_URL });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setStatus(data.error || `Failed (${res.status})`, 'err');
|
||||
sendEl.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
setStatus('Network error — try again.', 'err');
|
||||
sendEl.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,15 @@
|
||||
# Webpack build output — re-generated by `pnpm build`. The packaged
|
||||
# extension lives in `dist/` and is what you load unpacked in
|
||||
# `chrome://extensions`.
|
||||
dist/
|
||||
|
||||
# Per-package node_modules (the workspace hoists most of it to the root,
|
||||
# but pnpm still drops a `.pnpm` symlink tree here).
|
||||
node_modules/
|
||||
|
||||
# TypeScript incremental build artefacts.
|
||||
*.tsbuildinfo
|
||||
|
||||
# Editor / OS noise.
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,233 @@
|
||||
# Send to Readest — browser extension
|
||||
|
||||
One-click capture of the current web page into your Readest library as a
|
||||
**self-contained EPUB**. Built for Chromium-based browsers (Chrome, Edge,
|
||||
Arc, Brave). Manifest V3.
|
||||
|
||||
## What it captures
|
||||
|
||||
For every page the user clips, the extension produces a single `.epub`:
|
||||
|
||||
- Article body via [@mozilla/readability](https://github.com/mozilla/readability)
|
||||
on the live, fully-rendered DOM — so paywalled / authenticated pages
|
||||
capture the content the user can actually see.
|
||||
- Lazy-loaded images surfaced by walking the DOM and resolving `srcset`,
|
||||
`<picture>`/`<source>`, `data-src`, `data-original`, `data-srcset`,
|
||||
`data-lazy`, and `data-actualsrc` in that order of preference (the same
|
||||
set the server-side bundler in `src/services/send/conversion/assetBundler.ts`
|
||||
handles).
|
||||
- Image bytes fetched by the **service worker** under the user's existing
|
||||
session (`credentials: 'include'`) and the extension's broad
|
||||
`host_permissions: ["<all_urls>"]` — CORS doesn't apply, paywalled CDN
|
||||
cookies do.
|
||||
- A small bundled stylesheet (system fonts only — no remote fonts), so the
|
||||
EPUB never makes a network request when opened offline.
|
||||
- Inline images stored under `OEBPS/images/<sha256>.<ext>`, deduplicated by
|
||||
hash so a hero shared between `<picture>` and `<img>` only ships once.
|
||||
|
||||
The EPUB is POSTed to **`POST /api/send/inbox/file`** with `kind=file`. The
|
||||
server writes the bytes to R2 and inserts a `send_inbox` row; the next
|
||||
Readest client to open drains the inbox and imports the EPUB as-is — no
|
||||
further server-side conversion.
|
||||
|
||||
## Why client-side conversion
|
||||
|
||||
The previous version (`0.1.0`) sent only the page URL — the server then
|
||||
tried to fetch and render it. That broke on:
|
||||
|
||||
- Paywalled / member-only content (server doesn't have the user's cookies).
|
||||
- Bot-protected CDNs (Cloudflare, image hosts that gate on UA + Sec-Ch-Ua
|
||||
headers + JS challenges).
|
||||
- Lazy-loaded images that never materialize without a real scroll.
|
||||
|
||||
Building the EPUB on the capturing client side-steps all three. See
|
||||
[D5 in the Send to Readest plan](../../docs/) and Part 4a / Part 8 of the
|
||||
plan for the original design.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
popup (popup.ts)
|
||||
│ click "Send to Readest"
|
||||
▼
|
||||
service worker (background/service-worker.ts)
|
||||
│ chrome.scripting.executeScript({ files: ['content/capture.js'] })
|
||||
▼
|
||||
content script (content/capture.ts) [runs in the page's tab]
|
||||
├─ scrolls page once to materialize lazy images
|
||||
├─ flattens open Shadow DOM (content/capture/shadow.ts)
|
||||
├─ Readability extracts article body
|
||||
├─ walks <img>/<picture>, rewrites src → placeholder tokens
|
||||
├─ DOMPurify-sanitizes the article HTML
|
||||
└─ returns { meta, articleHtml, images:[{placeholder,url}] }
|
||||
▼
|
||||
service worker
|
||||
├─ fetchAssets(images) — CORS-free, credentialed image downloads
|
||||
├─ buildEpub — zip.js: mimetype, container, OPF, NCX, CSS, chapter, images
|
||||
└─ uploadEpub — POST /api/send/inbox/file with EPUB bytes
|
||||
▼
|
||||
server (src/pages/api/send/inbox/file.ts)
|
||||
├─ putObject → R2 (inbox bucket, kind='file')
|
||||
└─ insert send_inbox row
|
||||
▼
|
||||
next Readest open → drainer imports the EPUB → book in library on all devices
|
||||
```
|
||||
|
||||
A second always-on content script (`content/auth-bridge.ts`) runs only on
|
||||
`web.readest.com` and copies the user's Supabase access token into the
|
||||
extension's `chrome.storage.local` so the popup can authenticate to the
|
||||
inbox endpoint without prompting for credentials. The extension never
|
||||
stores a password or refresh token.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# From the extension directory:
|
||||
pnpm install # one-time — the extension is a pnpm workspace package
|
||||
pnpm build # produces dist/ ready to load unpacked
|
||||
pnpm dev # watch mode while developing
|
||||
```
|
||||
|
||||
The build is webpack-based:
|
||||
|
||||
- `src/background/service-worker.ts` → `dist/background/service-worker.js`
|
||||
(bundles `@zip.js/zip.js`)
|
||||
- `src/content/capture.ts` → `dist/content/capture.js`
|
||||
(bundles `@mozilla/readability` + `dompurify`)
|
||||
- `src/content/auth-bridge.ts` → `dist/content/auth-bridge.js`
|
||||
- `src/popup/popup.ts` → `dist/popup/popup.js`
|
||||
|
||||
`manifest.json`, `popup.html`, and `icons/*` are copied verbatim into
|
||||
`dist/` by `copy-webpack-plugin`.
|
||||
|
||||
## Load it for development
|
||||
|
||||
1. `pnpm build` (or `pnpm dev` for watch mode).
|
||||
2. Open `chrome://extensions`, enable **Developer mode**.
|
||||
3. **Load unpacked** → select this directory's `dist/` folder (not the
|
||||
project root).
|
||||
4. Visit <https://web.readest.com> once and sign in so the auth-bridge
|
||||
content script captures the access token.
|
||||
5. Click the extension's toolbar icon on any article page. The popup
|
||||
reflects each phase: capturing → fetching images → building EPUB →
|
||||
sending.
|
||||
|
||||
### Pointing the extension at a local Readest
|
||||
|
||||
The extension reads `chrome.storage.local.readestApiBase` if set, falling
|
||||
back to `https://web.readest.com`. From the DevTools console of the
|
||||
extension's background page:
|
||||
|
||||
```js
|
||||
chrome.storage.local.set({ readestApiBase: 'http://localhost:3000' });
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest exercises the extension's shell — upload (`X-Readest-*` headers, RFC
|
||||
5987 encoding, error-code mapping, endpoint override), auth bridge
|
||||
(`sb-*-auth-token` localStorage → `chrome.storage.local` sync, including
|
||||
malformed JSON + storage-event rotation), `chrome.storage` auth helpers,
|
||||
toolbar badge updates, the lazy-load scroll dance (incl.
|
||||
`prefers-reduced-motion`), and the popup UI rendering for every progress
|
||||
phase. From the `apps/readest-app` workspace root:
|
||||
|
||||
```bash
|
||||
pnpm test:extension # 47 shell tests, ~1 s
|
||||
pnpm build-browser-ext # production webpack build (catches alias / stub regressions)
|
||||
pnpm test # full suite — also runs the extension tests via vitest's default glob
|
||||
```
|
||||
|
||||
The shared EPUB pipeline (`convertPageToEpub`) and the server's
|
||||
`/api/send/inbox/file` endpoint have their own tests under
|
||||
`apps/readest-app/src/__tests__/services/`. The unification regression
|
||||
specifically lives in `send-convert-page-unified.test.ts`.
|
||||
|
||||
**CI coverage:** the GitHub Actions `test_web_app` job runs
|
||||
`pnpm test:pr:web`, which invokes the full vitest suite (extension shell
|
||||
tests included), the browser-test suite, and the extension's production
|
||||
webpack build. A webpack-config or shared-pipeline regression fails the
|
||||
job on its own line.
|
||||
|
||||
## Internationalisation
|
||||
|
||||
The extension uses **key-as-content** i18n (matches the readest-app's
|
||||
`stubTranslation as _` convention): the English source string IS the
|
||||
lookup key. Import as `_` at every call site to mirror the main repo:
|
||||
|
||||
```ts
|
||||
import { translate as _ } from '../lib/i18n';
|
||||
|
||||
_('Send to Readest');
|
||||
_('Sent — {count} images could not be fetched.', { count });
|
||||
```
|
||||
|
||||
Two parallel translation surfaces:
|
||||
|
||||
| Folder | Scope | When it's read |
|
||||
|---|---|---|
|
||||
| `src/locales/<lang>.json` | 29 runtime UI strings — popup, errors, status, badges. `{ "<english source>": "<translation>" }`. | At runtime by the `_(...)` helper. Falls through to the English key when an entry is missing or set to the `__STRING_NOT_TRANSLATED__` sentinel. |
|
||||
| `_locales/<lang>/messages.json` | Three manifest fields — `app_name`, `app_description`, `action_title` — referenced as `__MSG_*__` in `manifest.json`. | At install time + by the Chrome Web Store listing. Chrome falls back to `default_locale` (en) automatically, so a locale file is only needed when you want to override the toolbar tooltip / store copy. |
|
||||
|
||||
The full set of supported locales lives in
|
||||
`apps/readest-app/i18n-langs.json`. The extension's extractor reads the
|
||||
same file, so a locale added there ships in the extension automatically
|
||||
on the next `pnpm i18n:extract` run.
|
||||
|
||||
### Extracting strings
|
||||
|
||||
After adding `_('...')` calls or `data-i18n="..."` attrs:
|
||||
|
||||
```bash
|
||||
pnpm i18n:extract # populates every src/locales/*.json with new keys
|
||||
pnpm i18n:check # exits non-zero if any bundle has untranslated entries
|
||||
```
|
||||
|
||||
The extractor:
|
||||
|
||||
1. Reads the canonical locale list from
|
||||
`apps/readest-app/i18n-langs.json` and ensures a stub
|
||||
`src/locales/<lang>.json` exists for every entry (creates an empty
|
||||
`{}` for any missing locale).
|
||||
2. Scans every `.ts`/`.tsx` (skipping `*.test.ts`) and every `.html` file
|
||||
under the extension.
|
||||
3. Pulls source strings from `_('…')` calls AND `data-i18n` /
|
||||
`data-i18n-title` HTML attributes.
|
||||
4. For every non-`en` locale: adds missing entries with
|
||||
`__STRING_NOT_TRANSLATED__` (same sentinel readest-app uses), keeps
|
||||
existing translations, sorts keys for deterministic diffs.
|
||||
5. Regenerates `src/locales/index.ts` with one static import per locale
|
||||
bundle — the runtime helper reads its `bundles` map.
|
||||
6. Mirrors the locale list into `_locales/<lang>/messages.json` stubs
|
||||
(Chrome's native i18n surface for manifest fields). Existing
|
||||
translations are never overwritten — only missing locales get
|
||||
created as `__STRING_NOT_TRANSLATED__` stubs.
|
||||
7. Logs orphan entries (in the bundle but no longer in code) so a
|
||||
translator can decide whether to drop them.
|
||||
|
||||
The runtime helper filters sentinel entries at load time, so a
|
||||
partially-translated bundle gracefully falls back to English per-key
|
||||
instead of leaking placeholders into the UI.
|
||||
|
||||
### Adding a locale
|
||||
|
||||
To add a locale the extension ships in lockstep with the main app:
|
||||
|
||||
1. Add the code to `apps/readest-app/i18n-langs.json`.
|
||||
2. `pnpm i18n:extract` from the extension dir — creates
|
||||
`src/locales/<code>.json` populated with `__STRING_NOT_TRANSLATED__`
|
||||
placeholders, regenerates `src/locales/index.ts`.
|
||||
3. Hand-translate each value.
|
||||
4. *(Optional)* Drop `_locales/<code>/messages.json` mirroring the en
|
||||
file if you want the toolbar tooltip / Chrome Web Store listing
|
||||
translated too. Chrome falls back to `en` when this is missing.
|
||||
5. Rebuild — webpack picks up the new JSON via the regenerated index.
|
||||
|
||||
## Before publishing to the Chrome Web Store
|
||||
|
||||
- Replace the icon set (currently a 1:1 downscale of the Readest app icon)
|
||||
with extension-specific artwork.
|
||||
- Add a screenshot bundle and a privacy disclosure: the extension reads the
|
||||
page DOM and fetches its image references; nothing is sent off-device
|
||||
except to the Readest inbox.
|
||||
- Submit through the Chrome Web Store Developer Dashboard (review required).
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "إرسال إلى Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "احفظ صفحات الويب في مكتبة Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "إرسال إلى Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest-এ পাঠান",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "ওয়েব পৃষ্ঠাগুলিকে আপনার Readest লাইব্রেরিতে সংরক্ষণ করুন",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest-এ পাঠান",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest ལ་གཏོང་།",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "དྲ་ཤོག་Readest་དཔེ་མཛོད་དུ་ཉར་ཚགས་བྱེད",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest ལ་གཏོང་།",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "An Readest senden",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Speichere Webseiten in deiner Readest-Bibliothek",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "An Readest senden",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Αποστολή στο Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Αποθηκεύστε ιστοσελίδες στη βιβλιοθήκη Readest σας",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Αποστολή στο Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Send to Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Save web pages to your Readest library",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Send to Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Enviar a Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Guarda páginas web en tu biblioteca de Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Enviar a Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "ارسال به Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "صفحات وب را در کتابخانه Readest خود ذخیره کنید",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "ارسال به Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Envoyer vers Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Enregistrez des pages web dans votre bibliothèque Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Envoyer vers Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "שלח ל-Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "שמור דפי אינטרנט בספרייה שלך ב-Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "שלח ל-Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest पर भेजें",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "वेब पृष्ठों को आपकी Readest लाइब्रेरी में सहेजें",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest पर भेजें",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Küldés a Readestbe",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Mentsd a weboldalakat a Readest-könyvtáradba",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Küldés a Readestbe",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Kirim ke Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Simpan halaman web di perpustakaan Readest Anda",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Kirim ke Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Invia a Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Salva pagine web nella tua libreria Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Invia a Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest に送信",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "ウェブページを Readest ライブラリに保存します",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest に送信",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest로 보내기",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "웹페이지를 Readest 라이브러리에 저장합니다",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest로 보내기",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Hantar ke Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Simpan halaman web dalam perpustakaan Readest anda",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Hantar ke Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Naar Readest sturen",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Sla webpagina’s op in je Readest-bibliotheek",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Naar Readest sturen",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Wyślij do Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Zapisuj strony internetowe w bibliotece Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Wyślij do Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Enviar para o Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Salve páginas web em sua biblioteca Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Enviar para o Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Enviar para Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Guarde páginas web na sua biblioteca Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Enviar para Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Trimite la Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Salvează pagini web în biblioteca ta Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Trimite la Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Отправить в Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Сохраняйте веб-страницы в библиотеке Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Отправить в Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest වෙත යවන්න",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "වෙබ් පිටු ඔබේ Readest පුස්තකාලයට සුරකින්න",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest වෙත යවන්න",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Pošlji v Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Spletne strani shranite v svojo knjižnico Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Pošlji v Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Skicka till Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Spara webbsidor i ditt Readest-bibliotek",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Skicka till Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest-க்கு அனுப்பு",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "இணைய பக்கங்களை உங்கள் Readest நூலகத்தில் சேமிக்கவும்",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest-க்கு அனுப்பு",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "ส่งไปยัง Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "บันทึกหน้าเว็บลงในคลัง Readest ของคุณ",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "ส่งไปยัง Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest'e gönder",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Web sayfalarını Readest kitaplığınıza kaydedin",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest'e gönder",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Надіслати до Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Зберігайте веб-сторінки у вашій бібліотеці Readest",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Надіслати до Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Readest'ga yuborish",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Veb-sahifalarni Readest kutubxonangizga saqlang",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Readest'ga yuborish",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "Gửi đến Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "Lưu trang web vào thư viện Readest của bạn",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "Gửi đến Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "发送到 Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "将网页保存到你的 Readest 书库",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "发送到 Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "傳送至 Readest",
|
||||
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
|
||||
},
|
||||
"app_description": {
|
||||
"message": "將網頁儲存至您的 Readest 書庫",
|
||||
"description": "Short description shown in the Chrome Web Store listing."
|
||||
},
|
||||
"action_title": {
|
||||
"message": "傳送至 Readest",
|
||||
"description": "Tooltip on the toolbar action button."
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_app_name__",
|
||||
"version": "0.2.0",
|
||||
"description": "__MSG_app_description__",
|
||||
"default_locale": "en",
|
||||
"permissions": ["activeTab", "scripting", "storage", "offscreen"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"action": {
|
||||
"default_popup": "popup/popup.html",
|
||||
"default_title": "__MSG_action_title__",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png",
|
||||
"256": "icons/icon-256.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png",
|
||||
"256": "icons/icon-256.png"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background/service-worker.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://web.readest.com/*",
|
||||
"https://*.readest.com/*",
|
||||
"http://localhost:3000/*"
|
||||
],
|
||||
"js": ["content/auth-bridge.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Send to Readest — converter</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- `defer` is fine here: the SW polls for a `ping` ack before
|
||||
sending the real clip request (see `waitForOffscreenReady`),
|
||||
so any race between createDocument resolving and the script
|
||||
finishing executes is absorbed by the retry loop. -->
|
||||
<script src="offscreen.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@readest/send-to-readest-extension",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Browser extension that clips the current web page into the user's Readest library as a self-contained EPUB.",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"dev": "webpack --watch --mode development",
|
||||
"clean": "rimraf dist",
|
||||
"i18n:extract": "node scripts/extract-i18n.mjs",
|
||||
"i18n:check": "node scripts/extract-i18n.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@zip.js/zip.js": "^2.8.16",
|
||||
"dompurify": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "^5.6.3",
|
||||
"webpack": "^5.97.1",
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Send to Readest</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #666;
|
||||
--surface: #ffffff;
|
||||
--raised: #f5f5f5;
|
||||
--border: #e5e5e5;
|
||||
--accent: #1a1a1a;
|
||||
--accent-fg: #ffffff;
|
||||
--ok: #1e8e3e;
|
||||
--err: #c0392b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #f0f0f0;
|
||||
--muted: #a8a8a8;
|
||||
--surface: #1a1a1a;
|
||||
--raised: #262626;
|
||||
--border: #333;
|
||||
--accent: #f0f0f0;
|
||||
--accent-fg: #1a1a1a;
|
||||
--ok: #4ade80;
|
||||
--err: #f87171;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
}
|
||||
body {
|
||||
width: 320px;
|
||||
padding: 16px;
|
||||
font:
|
||||
13px/1.45 system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.signed-out-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--raised);
|
||||
color: var(--muted);
|
||||
}
|
||||
.page {
|
||||
background: var(--raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.page-url {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
button.primary {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
button.link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.progress {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
.progress.show {
|
||||
display: block;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
.progress-bar.indeterminate .progress-bar-fill {
|
||||
width: 30%;
|
||||
animation: scan 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scan {
|
||||
0% {
|
||||
margin-left: -30%;
|
||||
}
|
||||
100% {
|
||||
margin-left: 100%;
|
||||
}
|
||||
}
|
||||
.status {
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Only reserve vertical space once there's actual text — an empty
|
||||
status used to leave a 16 px gap below the progress bar that made
|
||||
the bottom padding look bigger than the top. */
|
||||
.status:not(:empty) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.status.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.status.err {
|
||||
color: var(--err);
|
||||
}
|
||||
[hidden],
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.sign-in {
|
||||
text-align: center;
|
||||
padding: 20px 0 4px;
|
||||
}
|
||||
.sign-in p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 data-i18n="Send to Readest"></h1>
|
||||
<span id="auth-badge" class="signed-out-badge hidden" data-i18n="Signed out"></span>
|
||||
</header>
|
||||
|
||||
<section id="signed-in-view">
|
||||
<div class="page">
|
||||
<p id="page-title" class="page-title" data-i18n="Loading…"></p>
|
||||
<p id="page-url" class="page-url"></p>
|
||||
</div>
|
||||
<button id="send" class="primary" disabled data-i18n="Send to Readest"></button>
|
||||
<div id="progress" class="progress">
|
||||
<div class="progress-label" id="progress-label"></div>
|
||||
<div class="progress-bar indeterminate"><div class="progress-bar-fill"></div></div>
|
||||
</div>
|
||||
<p id="status" class="status"></p>
|
||||
</section>
|
||||
|
||||
<section id="signed-out-view" class="sign-in hidden">
|
||||
<p data-i18n="Sign in to Readest to start clipping pages."></p>
|
||||
<button id="open-readest" class="primary" data-i18n="Open web.readest.com"></button>
|
||||
</section>
|
||||
|
||||
<script src="popup.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract runtime i18n source strings from the extension and merge them
|
||||
* into each locale bundle under `src/i18n/`. Mirrors the readest-app's
|
||||
* `i18n:extract` flow and shares its `_(...)` call convention:
|
||||
*
|
||||
* - `_('English source', { ...vars })` — key-as-content
|
||||
* - `data-i18n="English source"` HTML attributes
|
||||
* - `data-i18n-title="English source"` HTML attributes
|
||||
*
|
||||
* en.json stays `{}` by design — the runtime helper falls through to
|
||||
* the key when no translation exists. For every other locale we:
|
||||
* - add missing keys with `__STRING_NOT_TRANSLATED__` (matches the
|
||||
* readest-app sentinel)
|
||||
* - keep existing translations
|
||||
* - log (don't delete) orphan keys so a translator can decide
|
||||
*
|
||||
* Flags:
|
||||
* --check exit non-zero when any locale has untranslated entries
|
||||
* (used by the CI build to keep the bundle complete)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = dirname(__dirname);
|
||||
const APP_ROOT = join(ROOT, '..', '..');
|
||||
const LOCALES_DIR = join(ROOT, 'src', 'locales');
|
||||
const LANGS_FILE = join(APP_ROOT, 'i18n-langs.json');
|
||||
const INDEX_FILE = join(LOCALES_DIR, 'index.ts');
|
||||
const CHROME_LOCALES_DIR = join(ROOT, '_locales');
|
||||
const SENTINEL = '__STRING_NOT_TRANSLATED__';
|
||||
|
||||
/** Locale codes the extension ships — same canonical list as readest-app. */
|
||||
function loadLangs() {
|
||||
if (!existsSync(LANGS_FILE)) return ['en'];
|
||||
const langs = JSON.parse(readFileSync(LANGS_FILE, 'utf8'));
|
||||
// en isn't in the upstream list (it's the source language); add it
|
||||
// so we still touch / preserve `src/i18n/en.json`.
|
||||
return ['en', ...langs];
|
||||
}
|
||||
|
||||
/** Recursively walk a directory and yield every file matching `match`. */
|
||||
function* walk(dir, match) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
||||
yield* walk(path, match);
|
||||
} else if (entry.isFile() && match(path)) {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull every `_('source', ...)` call, captures quoted literal arg only.
|
||||
* The leading `\b` won't match here because `_` is a word character on
|
||||
* both sides of the boundary, so we use a non-word lookbehind via
|
||||
* `(?:^|[^\w])` and capture group 1 just to anchor without consuming
|
||||
* surrounding context. */
|
||||
const RE_T_CALL = /(?:^|[^\w])_\(\s*(["'`])((?:\\.|(?!\1).)*?)\1\s*[,)]/g;
|
||||
/** Pull every `data-i18n="source"` and `data-i18n-title="source"` attr. */
|
||||
const RE_DATA_I18N = /\bdata-i18n(?:-title)?=("|')((?:\\.|(?!\1).)*?)\1/g;
|
||||
|
||||
function unescapeLiteral(s) {
|
||||
return s
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\(['"`\\])/g, '$1');
|
||||
}
|
||||
|
||||
function extract() {
|
||||
const keys = new Set();
|
||||
// TypeScript source — skip *.test.ts (assertion text isn't user-facing).
|
||||
const tsFiles = [...walk(join(ROOT, 'src'), (p) => /\.tsx?$/.test(p) && !/\.test\.tsx?$/.test(p))];
|
||||
for (const file of tsFiles) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
for (const m of src.matchAll(RE_T_CALL)) keys.add(unescapeLiteral(m[2]));
|
||||
}
|
||||
// HTML files at the extension root — popup.html / offscreen.html — and
|
||||
// any nested HTML the build picks up.
|
||||
const htmlFiles = [
|
||||
...walk(ROOT, (p) => /\.html$/.test(p) && !p.includes('/dist/') && !p.includes('/node_modules/')),
|
||||
];
|
||||
for (const file of htmlFiles) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
for (const m of src.matchAll(RE_DATA_I18N)) keys.add(unescapeLiteral(m[2]));
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function loadBundle(path) {
|
||||
if (!existsSync(path)) return {};
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
function writeBundle(path, bundle) {
|
||||
// Sort keys for deterministic diffs across runs.
|
||||
const sorted = Object.fromEntries(Object.entries(bundle).sort(([a], [b]) => a.localeCompare(b)));
|
||||
writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function localeFiles() {
|
||||
// Source of truth is `apps/readest-app/i18n-langs.json` — make sure a
|
||||
// bundle file exists for every locale the main app ships. Create
|
||||
// missing ones as empty objects so the extractor's merge step
|
||||
// populates them on this run.
|
||||
if (!existsSync(LOCALES_DIR)) mkdirSync(LOCALES_DIR, { recursive: true });
|
||||
const codes = loadLangs();
|
||||
for (const code of codes) {
|
||||
const path = join(LOCALES_DIR, `${code}.json`);
|
||||
if (!existsSync(path)) writeFileSync(path, '{}\n');
|
||||
}
|
||||
return codes.map((code) => ({ code, path: join(LOCALES_DIR, `${code}.json`) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the locale list into Chrome's native `_locales/<lang>/messages.json`
|
||||
* so each locale carries the manifest-side translations (extension name,
|
||||
* description, action tooltip). Stubs use `__STRING_NOT_TRANSLATED__` —
|
||||
* Chrome falls back to `default_locale` on missing keys, so an unfilled
|
||||
* stub is harmless; the sentinel just makes the next translator's job
|
||||
* obvious. The en bundle is the canonical template for the schema (which
|
||||
* `description` annotations to keep).
|
||||
*/
|
||||
function seedChromeLocales() {
|
||||
const enPath = join(CHROME_LOCALES_DIR, 'en', 'messages.json');
|
||||
if (!existsSync(enPath)) return;
|
||||
const enBundle = JSON.parse(readFileSync(enPath, 'utf8'));
|
||||
const codes = loadLangs();
|
||||
for (const code of codes) {
|
||||
if (code === 'en') continue;
|
||||
const path = join(CHROME_LOCALES_DIR, code, 'messages.json');
|
||||
if (existsSync(path)) continue; // never overwrite a translated bundle
|
||||
const stub = {};
|
||||
for (const [key, entry] of Object.entries(enBundle)) {
|
||||
stub[key] = { ...entry, message: SENTINEL };
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(stub, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the static-imports index so every bundle in `i18n-langs.json`
|
||||
* is reachable at runtime. The runtime helper imports from this file —
|
||||
* keeping the imports static means both webpack and vitest resolve them
|
||||
* without any glob / require.context plumbing.
|
||||
*/
|
||||
function writeIndex(codes) {
|
||||
const sorted = [...codes].sort();
|
||||
const importLines = sorted.map((c) => `import ${jsIdent(c)}Messages from './${c}.json';`);
|
||||
const mapEntries = sorted.map((c) => ` '${c}': ${jsIdent(c)}Messages as Messages,`);
|
||||
const out = `// AUTO-GENERATED by \`pnpm i18n:extract\`. Do not edit by hand —
|
||||
// re-run the extractor whenever a locale is added or removed in
|
||||
// \`apps/readest-app/i18n-langs.json\`. The runtime in
|
||||
// \`src/lib/i18n.ts\` reads the bundle map exported from this file.
|
||||
|
||||
type Messages = Record<string, string>;
|
||||
|
||||
${importLines.join('\n')}
|
||||
|
||||
export const bundles: Record<string, Messages> = {
|
||||
${mapEntries.join('\n')}
|
||||
};
|
||||
`;
|
||||
writeFileSync(INDEX_FILE, out);
|
||||
}
|
||||
|
||||
/** Turn `zh-CN` into `zhCN` — a valid JS identifier we can name imports
|
||||
* after. Any non-alphanumeric becomes capitalised on the next letter. */
|
||||
function jsIdent(code) {
|
||||
return code.replace(/[-_](\w)/g, (_m, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check');
|
||||
const keys = extract();
|
||||
console.log(`[i18n] extracted ${keys.length} unique source strings`);
|
||||
|
||||
let untranslatedTotal = 0;
|
||||
let orphansTotal = 0;
|
||||
const localeList = localeFiles();
|
||||
|
||||
// Regenerate the static-imports map so the runtime sees every locale
|
||||
// listed in i18n-langs.json, not just the ones a developer happened
|
||||
// to hand-add.
|
||||
writeIndex(localeList.map((l) => l.code));
|
||||
|
||||
// Seed `_locales/<lang>/messages.json` stubs (Chrome native, manifest
|
||||
// fields). Existing translated bundles are never overwritten.
|
||||
seedChromeLocales();
|
||||
|
||||
for (const { code, path } of localeList) {
|
||||
const bundle = loadBundle(path);
|
||||
const next = { ...bundle };
|
||||
|
||||
// Add missing keys. en.json is intentionally `{}` — skip.
|
||||
if (code !== 'en') {
|
||||
for (const key of keys) {
|
||||
if (!(key in next)) next[key] = SENTINEL;
|
||||
}
|
||||
}
|
||||
|
||||
// Report orphans (keys in the bundle that no longer appear in code).
|
||||
// We don't auto-delete — a translator may still want to fix them
|
||||
// before they vanish on the next pass.
|
||||
const codeKeys = new Set(keys);
|
||||
const orphans = Object.keys(next).filter((k) => !codeKeys.has(k));
|
||||
const untranslated = Object.entries(next).filter(([, v]) => v === SENTINEL).length;
|
||||
|
||||
if (code !== 'en') {
|
||||
writeBundle(path, next);
|
||||
}
|
||||
untranslatedTotal += untranslated;
|
||||
orphansTotal += orphans.length;
|
||||
console.log(
|
||||
`[i18n] ${code}: ${Object.keys(next).length} keys, ` +
|
||||
`${untranslated} untranslated, ${orphans.length} orphan` +
|
||||
(orphans.length ? ` (${orphans.slice(0, 3).join(', ')}${orphans.length > 3 ? ', …' : ''})` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
if (check && untranslatedTotal > 0) {
|
||||
console.error(`[i18n] ${untranslatedTotal} untranslated entries — failing --check`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (check && orphansTotal > 0) {
|
||||
console.error(`[i18n] ${orphansTotal} orphan entries — failing --check`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Stubbed `chrome.*` surface for vitest. Only the APIs the extension shell
|
||||
* actually touches at module-load or test-execution time are filled in.
|
||||
*
|
||||
* Promise-returning shape matches MV3 (chrome.storage.local.get returns a
|
||||
* Promise<Record<string, unknown>>, not a callback). Spies are exposed so
|
||||
* tests can assert call counts / arguments.
|
||||
*/
|
||||
import { vi, type Mock } from 'vitest';
|
||||
|
||||
export interface ChromeMock {
|
||||
storage: {
|
||||
local: {
|
||||
get: Mock;
|
||||
set: Mock;
|
||||
remove: Mock;
|
||||
};
|
||||
session: {
|
||||
get: Mock;
|
||||
set: Mock;
|
||||
remove: Mock;
|
||||
};
|
||||
};
|
||||
action: {
|
||||
setBadgeText: Mock;
|
||||
setBadgeBackgroundColor: Mock;
|
||||
};
|
||||
tabs: {
|
||||
query: Mock;
|
||||
get: Mock;
|
||||
create: Mock;
|
||||
sendMessage: Mock;
|
||||
};
|
||||
scripting: {
|
||||
executeScript: Mock;
|
||||
};
|
||||
runtime: {
|
||||
sendMessage: Mock;
|
||||
connect: Mock;
|
||||
onMessage: { addListener: Mock };
|
||||
onConnect: { addListener: Mock };
|
||||
lastError: chrome.runtime.LastError | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset all spies and seed a fresh `storage.local` and `storage.session`. */
|
||||
export function installChromeMock(): ChromeMock {
|
||||
const localStore = new Map<string, unknown>();
|
||||
const sessionStore = new Map<string, unknown>();
|
||||
|
||||
const store = (s: Map<string, unknown>) => ({
|
||||
get: vi.fn(async (key?: string | string[]) => {
|
||||
if (key === undefined) {
|
||||
return Object.fromEntries(s);
|
||||
}
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of keys) {
|
||||
if (s.has(k)) out[k] = s.get(k);
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
set: vi.fn(async (entries: Record<string, unknown>) => {
|
||||
for (const [k, v] of Object.entries(entries)) s.set(k, v);
|
||||
}),
|
||||
remove: vi.fn(async (key: string | string[]) => {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
for (const k of keys) s.delete(k);
|
||||
}),
|
||||
});
|
||||
|
||||
const chromeMock: ChromeMock = {
|
||||
storage: {
|
||||
local: store(localStore),
|
||||
session: store(sessionStore),
|
||||
},
|
||||
action: {
|
||||
setBadgeText: vi.fn(async () => undefined),
|
||||
setBadgeBackgroundColor: vi.fn(async () => undefined),
|
||||
},
|
||||
tabs: {
|
||||
query: vi.fn(async () => []),
|
||||
get: vi.fn(async () => null),
|
||||
create: vi.fn(async () => undefined),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
scripting: {
|
||||
executeScript: vi.fn(async () => [{ frameId: 0, documentId: 'x' }]),
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
connect: vi.fn(() => ({
|
||||
name: '',
|
||||
postMessage: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
onMessage: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||
onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() },
|
||||
})),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onConnect: { addListener: vi.fn() },
|
||||
lastError: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
(globalThis as unknown as { chrome: ChromeMock }).chrome = chromeMock;
|
||||
return chromeMock;
|
||||
}
|
||||
|
||||
export function uninstallChromeMock(): void {
|
||||
delete (globalThis as unknown as { chrome?: unknown }).chrome;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import {
|
||||
installChromeMock,
|
||||
uninstallChromeMock,
|
||||
type ChromeMock,
|
||||
} from '../__test-utils__/chromeMock';
|
||||
import { setBadge } from './badge';
|
||||
|
||||
let chromeMock: ChromeMock;
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = installChromeMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstallChromeMock();
|
||||
});
|
||||
|
||||
describe('setBadge', () => {
|
||||
test('clears the badge with an empty text when phase is "clear"', () => {
|
||||
setBadge('clear');
|
||||
expect(chromeMock.action.setBadgeText).toHaveBeenCalledWith({ text: '' });
|
||||
expect(chromeMock.action.setBadgeBackgroundColor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('emits the labelled badge + colour for in-flight phases', () => {
|
||||
const cases: { phase: 'cap' | 'img' | 'epub' | 'send'; text: string }[] = [
|
||||
{ phase: 'cap', text: '…' },
|
||||
{ phase: 'img', text: 'IMG' },
|
||||
{ phase: 'epub', text: 'EPB' },
|
||||
{ phase: 'send', text: 'UP' },
|
||||
];
|
||||
for (const { phase, text } of cases) {
|
||||
chromeMock.action.setBadgeText.mockClear();
|
||||
chromeMock.action.setBadgeBackgroundColor.mockClear();
|
||||
setBadge(phase);
|
||||
expect(chromeMock.action.setBadgeText).toHaveBeenCalledWith({ text });
|
||||
// All in-flight phases use the brand blue.
|
||||
expect(chromeMock.action.setBadgeBackgroundColor).toHaveBeenCalledWith({
|
||||
color: '#1a73e8',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('"ok" uses the success colour and check mark', () => {
|
||||
setBadge('ok');
|
||||
expect(chromeMock.action.setBadgeText).toHaveBeenCalledWith({ text: '✓' });
|
||||
expect(chromeMock.action.setBadgeBackgroundColor).toHaveBeenCalledWith({
|
||||
color: '#1e8e3e',
|
||||
});
|
||||
});
|
||||
|
||||
test('"err" uses the error colour and bang', () => {
|
||||
setBadge('err');
|
||||
expect(chromeMock.action.setBadgeText).toHaveBeenCalledWith({ text: '!' });
|
||||
expect(chromeMock.action.setBadgeBackgroundColor).toHaveBeenCalledWith({
|
||||
color: '#c0392b',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Visible toolbar badge feedback. The popup auto-closes the moment the user
|
||||
* tabs away, and not everyone opens the SW console — the badge is the most
|
||||
* unambiguous "is anything happening" signal we have.
|
||||
*/
|
||||
|
||||
type Phase = 'cap' | 'img' | 'epub' | 'send' | 'ok' | 'err' | 'clear';
|
||||
|
||||
const COLORS: Record<Exclude<Phase, 'clear'>, string> = {
|
||||
cap: '#1a73e8',
|
||||
img: '#1a73e8',
|
||||
epub: '#1a73e8',
|
||||
send: '#1a73e8',
|
||||
ok: '#1e8e3e',
|
||||
err: '#c0392b',
|
||||
};
|
||||
|
||||
const LABELS: Record<Exclude<Phase, 'clear'>, string> = {
|
||||
cap: '…',
|
||||
img: 'IMG',
|
||||
epub: 'EPB',
|
||||
send: 'UP',
|
||||
ok: '✓',
|
||||
err: '!',
|
||||
};
|
||||
|
||||
export function setBadge(phase: Phase): void {
|
||||
if (phase === 'clear') {
|
||||
chrome.action.setBadgeText({ text: '' }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
chrome.action.setBadgeBackgroundColor({ color: COLORS[phase] }).catch(() => undefined);
|
||||
chrome.action.setBadgeText({ text: LABELS[phase] }).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Spawn an offscreen document the first time the SW needs to convert a
|
||||
* page, then reuse it for the lifetime of the SW. Closing it costs ~30 ms
|
||||
* on the next clip, so we let it sit idle — Chrome reclaims it when the
|
||||
* SW itself shuts down.
|
||||
*
|
||||
* The offscreen page exists because MV3 service workers don't expose
|
||||
* `DOMParser` / `Document`, which the shared `convertPageToEpub` relies
|
||||
* on (Readability, asset bundler, heading extractor, etc.). It also
|
||||
* performs the **upload** — we avoid sending the EPUB bytes back to the
|
||||
* SW because `chrome.runtime.sendMessage` does not reliably structured-
|
||||
* clone `ArrayBuffer` between extension contexts; the bytes come through
|
||||
* JSON-serialized as `{}`, which wrapped in `new Blob([{}])` becomes the
|
||||
* literal text "[object Object]" — exactly what the inbox drainer was
|
||||
* rejecting as a corrupt EPUB.
|
||||
*/
|
||||
|
||||
import type { ClipErrorCode } from '../lib/messages';
|
||||
import { translate as _ } from '../lib/i18n';
|
||||
import { resolveUploadEndpoint } from './upload';
|
||||
|
||||
const OFFSCREEN_URL = chrome.runtime.getURL('offscreen.html');
|
||||
|
||||
let ensurePromise: Promise<void> | null = null;
|
||||
|
||||
async function offscreenDocumentExists(): Promise<boolean> {
|
||||
const off = chrome.offscreen as typeof chrome.offscreen & {
|
||||
hasDocument?: () => Promise<boolean>;
|
||||
};
|
||||
if (off?.hasDocument) {
|
||||
try {
|
||||
return await off.hasDocument();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const runtime = chrome.runtime as typeof chrome.runtime & {
|
||||
getContexts?: (filter: { contextTypes: string[] }) => Promise<unknown[]>;
|
||||
};
|
||||
if (runtime.getContexts) {
|
||||
const contexts = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
||||
return contexts.length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureOffscreenDocument(): Promise<void> {
|
||||
if (await offscreenDocumentExists()) {
|
||||
await waitForOffscreenReady();
|
||||
return;
|
||||
}
|
||||
if (ensurePromise) return ensurePromise;
|
||||
|
||||
ensurePromise = (async (): Promise<void> => {
|
||||
try {
|
||||
await chrome.offscreen.createDocument({
|
||||
url: OFFSCREEN_URL,
|
||||
reasons: [chrome.offscreen.Reason.DOM_PARSER],
|
||||
justification:
|
||||
'Parse and convert the captured page HTML to a self-contained EPUB, then upload to the user inbox. The MV3 service worker has no DOMParser, and EPUB bytes do not survive runtime.sendMessage serialization between contexts.',
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Race: another caller created it in the meantime. Harmless.
|
||||
if (!/single offscreen document/i.test(message)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
ensurePromise = null;
|
||||
}
|
||||
// The offscreen page may finish executing slightly after
|
||||
// `createDocument` resolves; poll for its `ping` reply so the first
|
||||
// real message never lands on a not-yet-listening page.
|
||||
await waitForOffscreenReady();
|
||||
})();
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
async function waitForOffscreenReady(): Promise<void> {
|
||||
// ~4 seconds of polling — well over the wall-clock cost of script
|
||||
// execution. `setTimeout` keeps the SW awake while waiting.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
const reply = (await chrome.runtime.sendMessage({ type: 'send-to-readest:ping' })) as
|
||||
| { ok?: boolean }
|
||||
| undefined;
|
||||
if (reply?.ok) return;
|
||||
} catch {
|
||||
/* offscreen not up yet — retry */
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(_('Offscreen page failed to start'));
|
||||
}
|
||||
|
||||
export interface ClipAndUploadResult {
|
||||
inboxId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
byteSize: number;
|
||||
}
|
||||
|
||||
export interface ClipAndUploadFailure {
|
||||
code: ClipErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the page snapshot + auth token to the offscreen page. It runs the
|
||||
* full conversion AND the upload inside its own realm, then returns a
|
||||
* small JSON-friendly payload — no ArrayBuffer crosses the boundary.
|
||||
*/
|
||||
/** Hard ceiling on the convert+upload round-trip. A 4 MB WeChat article
|
||||
* with ~15 images typically clears in under 20 s on a fast connection;
|
||||
* 90 s leaves slack for slow CDNs without letting a stuck handler hang
|
||||
* the popup forever. */
|
||||
const CLIP_TIMEOUT_MS = 90_000;
|
||||
|
||||
export async function clipAndUploadViaOffscreen(opts: {
|
||||
html: string;
|
||||
url: string;
|
||||
token: string;
|
||||
pageTitle: string;
|
||||
}): Promise<
|
||||
{ ok: true; result: ClipAndUploadResult } | { ok: false; failure: ClipAndUploadFailure }
|
||||
> {
|
||||
try {
|
||||
await ensureOffscreenDocument();
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
code: 'unknown',
|
||||
message: _('Could not start the converter page: {reason}', { reason }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the endpoint here in the SW — `chrome.storage` is reliable
|
||||
// in the SW context but has been observed `undefined` inside the
|
||||
// offscreen page on some Chrome builds, so we never make the offscreen
|
||||
// page read storage itself.
|
||||
const endpoint = await resolveUploadEndpoint();
|
||||
|
||||
const sendPromise = chrome.runtime.sendMessage({
|
||||
type: 'send-to-readest:clip-and-upload',
|
||||
html: opts.html,
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
pageTitle: opts.pageTitle,
|
||||
endpoint,
|
||||
});
|
||||
const timeoutPromise = new Promise<undefined>((resolve) =>
|
||||
setTimeout(() => resolve(undefined), CLIP_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const response = (await Promise.race([sendPromise, timeoutPromise])) as
|
||||
| {
|
||||
ok: true;
|
||||
inboxId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
byteSize: number;
|
||||
}
|
||||
| { ok: false; code: ClipErrorCode; message: string }
|
||||
| undefined;
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
code: 'unknown',
|
||||
message: _('Conversion timed out after {seconds}s', {
|
||||
seconds: CLIP_TIMEOUT_MS / 1000,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { ok: false, failure: { code: response.code, message: response.message } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
inboxId: response.inboxId,
|
||||
title: response.title,
|
||||
author: response.author,
|
||||
byteSize: response.byteSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Send to Readest — background service worker. Orchestrates the clip:
|
||||
*
|
||||
* popup → SW: `send-to-readest:clip` (with tabId)
|
||||
* SW: injects `content/capture.js` into the active tab. The content
|
||||
* script opens a long-lived Port back to the SW — that keeps the
|
||||
* SW alive through the whole capture and gives us a clear
|
||||
* `onDisconnect` if the inject silently fails.
|
||||
* content script: snapshots `document.documentElement.outerHTML` and
|
||||
* sends it on the Port.
|
||||
* SW: hands the snapshot to the shared `convertPageToEpub` in
|
||||
* `src/services/send/conversion/` — Readability, per-site rule
|
||||
* fast-paths, asset bundling, cover generation, headings → TOC,
|
||||
* and the EPUB build all happen there. Same code path the
|
||||
* desktop / mobile `/send` URL clipping flow uses, so the EPUBs
|
||||
* are byte-identical for the same URL.
|
||||
* SW: POSTs the resulting EPUB to /api/send/inbox/file.
|
||||
* SW → popup: `send-to-readest:progress` broadcasts at each phase.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClipErrorCode,
|
||||
ClipProgress,
|
||||
ClipRequest,
|
||||
PageSnapshot,
|
||||
StatusRequest,
|
||||
StatusResponse,
|
||||
} from '../lib/messages';
|
||||
import { readToken } from '../lib/auth';
|
||||
import { translate as _ } from '../lib/i18n';
|
||||
import { setBadge } from './badge';
|
||||
import { clipAndUploadViaOffscreen } from './offscreen';
|
||||
|
||||
const LOG = '[send-to-readest/sw]';
|
||||
|
||||
interface State {
|
||||
inFlight: boolean;
|
||||
lastProgress: ClipProgress | null;
|
||||
}
|
||||
|
||||
const state: State = { inFlight: false, lastProgress: null };
|
||||
|
||||
function broadcast(progress: ClipProgress): void {
|
||||
state.lastProgress = progress;
|
||||
chrome.runtime.sendMessage({ type: 'send-to-readest:progress', progress }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function emitError(code: ClipErrorCode, message: string): void {
|
||||
console.warn(LOG, 'clip error', { code, message });
|
||||
setBadge('err');
|
||||
broadcast({ phase: 'error', code, message });
|
||||
state.inFlight = false;
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
snapshot: PageSnapshot | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface PendingCapture {
|
||||
resolve: (result: CaptureResult) => void;
|
||||
}
|
||||
|
||||
const CAPTURE_PORT_NAME = 'send-to-readest:capture';
|
||||
const CAPTURE_HARD_TIMEOUT_MS = 25_000;
|
||||
const PORT_CONNECT_GRACE_MS = 4_000;
|
||||
const pendingByTab = new Map<number, PendingCapture>();
|
||||
|
||||
async function injectCapture(tabId: number): Promise<CaptureResult> {
|
||||
const waitForResult = new Promise<CaptureResult>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: CaptureResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
pendingByTab.delete(tabId);
|
||||
resolve(result);
|
||||
};
|
||||
pendingByTab.set(tabId, { resolve: finish });
|
||||
setTimeout(() => finish({ snapshot: null, reason: 'hard-timeout' }), CAPTURE_HARD_TIMEOUT_MS);
|
||||
setTimeout(() => {
|
||||
const pending = pendingByTab.get(tabId);
|
||||
if (pending && !settled) {
|
||||
console.warn(
|
||||
LOG,
|
||||
'no port connected after grace window — the content script likely failed to inject',
|
||||
{ tabId, graceMs: PORT_CONNECT_GRACE_MS },
|
||||
);
|
||||
}
|
||||
}, PORT_CONNECT_GRACE_MS);
|
||||
});
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames: false },
|
||||
files: ['content/capture.js'],
|
||||
world: 'ISOLATED',
|
||||
});
|
||||
} catch (err) {
|
||||
pendingByTab.delete(tabId);
|
||||
console.warn(LOG, 'capture script inject failed', err);
|
||||
throw new Error(
|
||||
`Could not inject capture script: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return waitForResult;
|
||||
}
|
||||
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== CAPTURE_PORT_NAME) return;
|
||||
const tabId = port.sender?.tab?.id;
|
||||
if (typeof tabId !== 'number') {
|
||||
console.warn(LOG, 'capture port with no tab.id; closing');
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
let received = false;
|
||||
port.onMessage.addListener((msg: unknown) => {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
const m = msg as { snapshot?: PageSnapshot | null; reason?: string };
|
||||
received = true;
|
||||
const pending = pendingByTab.get(tabId);
|
||||
if (pending) {
|
||||
pending.resolve({ snapshot: m.snapshot ?? null, reason: m.reason });
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (!received) {
|
||||
const pending = pendingByTab.get(tabId);
|
||||
if (pending) pending.resolve({ snapshot: null, reason: 'port-disconnect' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function runClip(tabId: number): Promise<void> {
|
||||
if (state.inFlight) return;
|
||||
state.inFlight = true;
|
||||
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
||||
if (!tab || !tab.url) return emitError('no-active-tab', _('No active tab'));
|
||||
if (!/^https?:/i.test(tab.url)) {
|
||||
return emitError('restricted-page', _('This page cannot be clipped'));
|
||||
}
|
||||
|
||||
const stored = await readToken();
|
||||
if (!stored) {
|
||||
return emitError('not-signed-in', _('Sign in at web.readest.com first'));
|
||||
}
|
||||
|
||||
setBadge('cap');
|
||||
broadcast({ phase: 'capturing' });
|
||||
|
||||
let captureResult: CaptureResult;
|
||||
try {
|
||||
captureResult = await injectCapture(tabId);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return emitError(
|
||||
'capture-failed',
|
||||
_('Could not inject capture script: {reason}', { reason }),
|
||||
);
|
||||
}
|
||||
const snapshot = captureResult.snapshot;
|
||||
if (!snapshot) {
|
||||
const why = captureResult.reason;
|
||||
const friendly =
|
||||
why === 'hard-timeout'
|
||||
? _('Page took too long to read')
|
||||
: why === 'port-disconnect'
|
||||
? _('Capture script could not start on this page')
|
||||
: _("Couldn't read this page");
|
||||
return emitError('no-readable-content', friendly);
|
||||
}
|
||||
|
||||
// Convert + upload happen together in the offscreen document — the
|
||||
// EPUB bytes never cross the SW↔offscreen boundary so they can't be
|
||||
// mangled by runtime.sendMessage's JSON-serialization fallback. The
|
||||
// SW only sees a small JSON result.
|
||||
setBadge('epub');
|
||||
broadcast({ phase: 'converting' });
|
||||
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await clipAndUploadViaOffscreen({
|
||||
html: snapshot.html,
|
||||
url: snapshot.sourceUrl,
|
||||
token: stored.token,
|
||||
pageTitle: snapshot.pageTitle,
|
||||
});
|
||||
} catch (err) {
|
||||
return emitError('unknown', err instanceof Error ? err.message : _('Unknown error'));
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
return emitError(outcome.failure.code, outcome.failure.message);
|
||||
}
|
||||
|
||||
setBadge('ok');
|
||||
setTimeout(() => setBadge('clear'), 5_000);
|
||||
broadcast({ phase: 'done' });
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse): boolean => {
|
||||
if (!message || typeof message !== 'object') return false;
|
||||
const m = message as { type?: string };
|
||||
|
||||
if (m.type === 'send-to-readest:clip') {
|
||||
const { tabId } = message as ClipRequest;
|
||||
runClip(tabId).catch((err) =>
|
||||
emitError('unknown', err instanceof Error ? err.message : _('Unknown error')),
|
||||
);
|
||||
sendResponse({ accepted: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m.type === 'send-to-readest:status') {
|
||||
void (async (): Promise<void> => {
|
||||
const token = await readToken();
|
||||
const response: StatusResponse = {
|
||||
signedIn: token !== null,
|
||||
inFlight: state.inFlight,
|
||||
lastProgress: state.lastProgress,
|
||||
};
|
||||
sendResponse(response);
|
||||
})();
|
||||
void (message as StatusRequest);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
installChromeMock,
|
||||
uninstallChromeMock,
|
||||
type ChromeMock,
|
||||
} from '../__test-utils__/chromeMock';
|
||||
import { resolveUploadEndpoint, uploadEpub } from './upload';
|
||||
|
||||
let chromeMock: ChromeMock;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = installChromeMock();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
uninstallChromeMock();
|
||||
});
|
||||
|
||||
function blobOk(body: unknown, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
const sampleEpub = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], {
|
||||
type: 'application/epub+zip',
|
||||
});
|
||||
|
||||
const DEFAULT_ENDPOINT = 'https://web.readest.com/api/send/inbox/file';
|
||||
|
||||
const sampleArgs = {
|
||||
endpoint: DEFAULT_ENDPOINT,
|
||||
token: 'tok-abc',
|
||||
epub: sampleEpub,
|
||||
title: 'Hello, World',
|
||||
sourceUrl: 'https://example.com/article',
|
||||
};
|
||||
|
||||
describe('resolveUploadEndpoint', () => {
|
||||
test('defaults to the production endpoint when no override is set', async () => {
|
||||
expect(await resolveUploadEndpoint()).toBe('https://web.readest.com/api/send/inbox/file');
|
||||
});
|
||||
|
||||
test('honours readestApiBase in chrome.storage.local', async () => {
|
||||
await chromeMock.storage.local.set({ readestApiBase: 'http://localhost:3000' });
|
||||
expect(await resolveUploadEndpoint()).toBe('http://localhost:3000/api/send/inbox/file');
|
||||
});
|
||||
|
||||
test('strips a trailing slash from readestApiBase', async () => {
|
||||
await chromeMock.storage.local.set({ readestApiBase: 'http://localhost:3000/' });
|
||||
expect(await resolveUploadEndpoint()).toBe('http://localhost:3000/api/send/inbox/file');
|
||||
});
|
||||
|
||||
test('ignores readestApiBase when it is not http(s)', async () => {
|
||||
await chromeMock.storage.local.set({ readestApiBase: 'javascript:alert(1)' });
|
||||
expect(await resolveUploadEndpoint()).toBe('https://web.readest.com/api/send/inbox/file');
|
||||
});
|
||||
|
||||
test('falls back to default when chrome.storage is unavailable', async () => {
|
||||
// Mirror the offscreen-document quirk where `chrome.storage` came
|
||||
// back undefined — `resolveUploadEndpoint` must never throw.
|
||||
const realStorage = chromeMock.storage;
|
||||
(chromeMock as unknown as { storage: undefined }).storage = undefined;
|
||||
try {
|
||||
expect(await resolveUploadEndpoint()).toBe('https://web.readest.com/api/send/inbox/file');
|
||||
} finally {
|
||||
(chromeMock as unknown as { storage: typeof realStorage }).storage = realStorage;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadEpub', () => {
|
||||
test('POSTs the EPUB body with Authorization + RFC 5987 headers', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ id: 'inbox-1' }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
|
||||
expect(result).toEqual({ ok: true, id: 'inbox-1' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe('https://web.readest.com/api/send/inbox/file');
|
||||
expect(init?.method).toBe('POST');
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-abc');
|
||||
expect(headers['Content-Type']).toBe('application/epub+zip');
|
||||
// RFC 5987: `UTF-8''<percent-encoded>` so non-ASCII titles survive a
|
||||
// header round-trip.
|
||||
expect(headers['X-Readest-Title']).toBe("UTF-8''Hello%2C%20World");
|
||||
expect(headers['X-Readest-Url']).toBe("UTF-8''https%3A%2F%2Fexample.com%2Farticle");
|
||||
expect(init?.body).toBe(sampleEpub);
|
||||
});
|
||||
|
||||
test('encodes non-ASCII titles correctly', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ id: 'inbox-2' }));
|
||||
await uploadEpub({ ...sampleArgs, title: '机器学习 ✅' });
|
||||
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<string, string>;
|
||||
expect(headers['X-Readest-Title']).toBe(
|
||||
"UTF-8''%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%20%E2%9C%85",
|
||||
);
|
||||
});
|
||||
|
||||
test('POSTs to whichever endpoint URL the caller supplied (used by the SW to inject the resolved base)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ id: 'inbox-3' }));
|
||||
await uploadEpub({ ...sampleArgs, endpoint: 'http://localhost:3000/api/send/inbox/file' });
|
||||
expect(fetchSpy.mock.calls[0]![0]).toBe('http://localhost:3000/api/send/inbox/file');
|
||||
});
|
||||
|
||||
test('maps 401/403 to session-expired', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({}, { status: 401 }));
|
||||
const a = await uploadEpub(sampleArgs);
|
||||
expect(a).toEqual({ ok: false, code: 'session-expired', message: 'Session expired' });
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({}, { status: 403 }));
|
||||
const b = await uploadEpub(sampleArgs);
|
||||
expect(b.ok).toBe(false);
|
||||
if (!b.ok) expect(b.code).toBe('session-expired');
|
||||
});
|
||||
|
||||
test('maps 429 to inbox-full', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ error: 'Inbox is full' }, { status: 429 }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result).toEqual({ ok: false, code: 'inbox-full', message: 'Inbox is full' });
|
||||
});
|
||||
|
||||
test('maps 413 to a "too large" server-error', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ error: 'File is too large' }, { status: 413 }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('server-error');
|
||||
expect(result.message).toBe('Article is too large to send');
|
||||
}
|
||||
});
|
||||
|
||||
test('surfaces the server-supplied error message on other 5xx', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(blobOk({ error: 'Could not store EPUB' }, { status: 500 }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('server-error');
|
||||
expect(result.message).toBe('Could not store EPUB');
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to a status-code message when the server body is not JSON', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('plain text', { status: 502 }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('502');
|
||||
}
|
||||
});
|
||||
|
||||
test('maps network failures to network-error and names the target host', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('network-error');
|
||||
// The message now includes the target host so the user can tell at
|
||||
// a glance whether the override pointed at a dead local server.
|
||||
expect(result.message).toContain('web.readest.com');
|
||||
expect(result.message).toContain('Failed to fetch');
|
||||
}
|
||||
});
|
||||
|
||||
test('treats a non-JSON 200 body as a server-error', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('OK', { status: 200 }));
|
||||
const result = await uploadEpub(sampleArgs);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('server-error');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Upload a built EPUB to the user's Readest inbox. Posts the EPUB bytes
|
||||
* directly to `POST /api/send/inbox/file` with metadata in headers — the
|
||||
* server stores the bytes in R2 verbatim and the drainer imports the EPUB
|
||||
* on the next Readest open. We use a raw binary body (not multipart) so the
|
||||
* server-side handler can stream-read without pulling in a multipart parser.
|
||||
*/
|
||||
|
||||
import type { ClipErrorCode } from '../lib/messages';
|
||||
import { translate as _ } from '../lib/i18n';
|
||||
|
||||
const DEFAULT_API_BASE = 'https://web.readest.com';
|
||||
const INBOX_FILE_PATH = '/api/send/inbox/file';
|
||||
|
||||
/**
|
||||
* Compute the full upload endpoint URL. Reads `readestApiBase` from
|
||||
* `chrome.storage.local` so a developer can point the extension at a
|
||||
* local server via:
|
||||
*
|
||||
* chrome.storage.local.set({ readestApiBase: 'http://localhost:3000' });
|
||||
*
|
||||
* This is called from the service worker — `chrome.storage` is reliable
|
||||
* there. The offscreen page receives the resolved URL as a parameter so
|
||||
* it never touches `chrome.storage` itself (some Chrome builds expose
|
||||
* `chrome.runtime` to an offscreen document while leaving
|
||||
* `chrome.storage` undefined until the page has been alive for a beat).
|
||||
*/
|
||||
export async function resolveUploadEndpoint(): Promise<string> {
|
||||
try {
|
||||
if (chrome?.storage?.local) {
|
||||
const stored = (await chrome.storage.local.get('readestApiBase')) as {
|
||||
readestApiBase?: unknown;
|
||||
};
|
||||
const base = stored.readestApiBase;
|
||||
if (typeof base === 'string' && /^https?:\/\//.test(base)) {
|
||||
return `${base.replace(/\/$/, '')}${INBOX_FILE_PATH}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[send-to-readest/upload] could not read readestApiBase', err);
|
||||
}
|
||||
return `${DEFAULT_API_BASE}${INBOX_FILE_PATH}`;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
ok: true;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UploadError {
|
||||
ok: false;
|
||||
code: ClipErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** RFC 5987 encoding so non-ASCII titles survive HTTP-header transport. */
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return `UTF-8''${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
export async function uploadEpub(opts: {
|
||||
/** Full upload URL — caller resolves this (typically in the SW where
|
||||
* `chrome.storage` is reliable) so the offscreen page never has to
|
||||
* read storage itself. */
|
||||
endpoint: string;
|
||||
token: string;
|
||||
/** EPUB payload — `File` from the shared converter, or any `Blob`. */
|
||||
epub: Blob | File;
|
||||
title: string;
|
||||
sourceUrl: string;
|
||||
}): Promise<UploadResult | UploadError> {
|
||||
const target = opts.endpoint;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(target, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.token}`,
|
||||
'Content-Type': 'application/epub+zip',
|
||||
'X-Readest-Title': encodeHeaderValue(opts.title || 'Page clip'),
|
||||
'X-Readest-Url': encodeHeaderValue(opts.sourceUrl),
|
||||
},
|
||||
body: opts.epub,
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
code: 'network-error',
|
||||
message: _('Could not reach {host} — {reason}', {
|
||||
host: new URL(target).host,
|
||||
reason,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 401) {
|
||||
return { ok: false, code: 'session-expired', message: _('Session expired') };
|
||||
}
|
||||
if (res.status === 429) {
|
||||
return { ok: false, code: 'inbox-full', message: _('Inbox is full') };
|
||||
}
|
||||
if (res.status === 413) {
|
||||
return { ok: false, code: 'server-error', message: _('Article is too large to send') };
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = ((await res.json()) as { error?: string }).error ?? '';
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'server-error',
|
||||
message: detail || _('Server returned {status}', { status: res.status }),
|
||||
};
|
||||
}
|
||||
let body: { id?: string };
|
||||
try {
|
||||
body = (await res.json()) as { id?: string };
|
||||
} catch {
|
||||
return { ok: false, code: 'server-error', message: _('Unexpected server response') };
|
||||
}
|
||||
return { ok: true, id: body.id ?? '' };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
installChromeMock,
|
||||
uninstallChromeMock,
|
||||
type ChromeMock,
|
||||
} from '../__test-utils__/chromeMock';
|
||||
|
||||
/**
|
||||
* `auth-bridge.ts` is a side-effect content script — its module-load
|
||||
* IIFE syncs the token immediately and installs a `storage` event
|
||||
* listener. We use `vi.resetModules()` between cases so each `import`
|
||||
* re-runs that IIFE against the test's fresh localStorage.
|
||||
*/
|
||||
|
||||
let chromeMock: ChromeMock;
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = installChromeMock();
|
||||
localStorage.clear();
|
||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addEventListenerSpy.mockRestore();
|
||||
uninstallChromeMock();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('auth-bridge — token extraction', () => {
|
||||
test('writes a fresh token to chrome.storage when localStorage has a Supabase session', async () => {
|
||||
localStorage.setItem(
|
||||
'sb-projectref-auth-token',
|
||||
JSON.stringify({ access_token: 'abc-token', refresh_token: 'r' }),
|
||||
);
|
||||
await import('./auth-bridge');
|
||||
|
||||
expect(chromeMock.storage.local.set).toHaveBeenCalledTimes(1);
|
||||
const call = chromeMock.storage.local.set.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call['readestAccessToken']).toBe('abc-token');
|
||||
expect(typeof call['readestTokenAt']).toBe('number');
|
||||
});
|
||||
|
||||
test('clears the stored token when no Supabase session is present', async () => {
|
||||
// Pre-seed the extension storage so we can confirm clearing.
|
||||
await chromeMock.storage.local.set({
|
||||
readestAccessToken: 'stale',
|
||||
readestTokenAt: 12345,
|
||||
});
|
||||
chromeMock.storage.local.remove.mockClear();
|
||||
|
||||
await import('./auth-bridge');
|
||||
expect(chromeMock.storage.local.remove).toHaveBeenCalledWith([
|
||||
'readestAccessToken',
|
||||
'readestTokenAt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores keys not matching the Supabase auth-token pattern', async () => {
|
||||
localStorage.setItem('unrelated-key', JSON.stringify({ access_token: 'nope' }));
|
||||
localStorage.setItem('sb-foo-something-else', JSON.stringify({ access_token: 'nope2' }));
|
||||
|
||||
await import('./auth-bridge');
|
||||
// No valid sb-...-auth-token entry → should clear, not set.
|
||||
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chromeMock.storage.local.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('tolerates malformed JSON in a matching key', async () => {
|
||||
localStorage.setItem('sb-bad-auth-token', '{not valid json');
|
||||
|
||||
await expect(import('./auth-bridge')).resolves.toBeDefined();
|
||||
// The malformed entry counts as "no token found" → clear.
|
||||
expect(chromeMock.storage.local.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips entries whose access_token is missing or not a string', async () => {
|
||||
localStorage.setItem('sb-x-auth-token', JSON.stringify({ access_token: 42 }));
|
||||
localStorage.setItem('sb-y-auth-token', JSON.stringify({}));
|
||||
|
||||
await import('./auth-bridge');
|
||||
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('picks any matching key when several exist (first one wins is fine)', async () => {
|
||||
localStorage.setItem('sb-alpha-auth-token', JSON.stringify({ access_token: 'alpha-token' }));
|
||||
localStorage.setItem('sb-beta-auth-token', JSON.stringify({ access_token: 'beta-token' }));
|
||||
|
||||
await import('./auth-bridge');
|
||||
expect(chromeMock.storage.local.set).toHaveBeenCalledTimes(1);
|
||||
const stored = (chromeMock.storage.local.set.mock.calls[0]![0] as Record<string, unknown>)[
|
||||
'readestAccessToken'
|
||||
];
|
||||
expect(['alpha-token', 'beta-token']).toContain(stored);
|
||||
});
|
||||
|
||||
test('installs a `storage` event listener for token rotation', async () => {
|
||||
await import('./auth-bridge');
|
||||
const calls = addEventListenerSpy.mock.calls.filter((c) => c[0] === 'storage');
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('storage event with a non-sb key is ignored', async () => {
|
||||
await import('./auth-bridge');
|
||||
chromeMock.storage.local.set.mockClear();
|
||||
chromeMock.storage.local.remove.mockClear();
|
||||
|
||||
const handler = addEventListenerSpy.mock.calls.find((c) => c[0] === 'storage')?.[1] as (
|
||||
e: StorageEvent,
|
||||
) => void;
|
||||
handler(new StorageEvent('storage', { key: 'theme', newValue: 'dark' }));
|
||||
|
||||
expect(chromeMock.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chromeMock.storage.local.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('storage event with a matching key re-syncs the token', async () => {
|
||||
await import('./auth-bridge');
|
||||
chromeMock.storage.local.set.mockClear();
|
||||
|
||||
localStorage.setItem('sb-rotated-auth-token', JSON.stringify({ access_token: 'new-token' }));
|
||||
const handler = addEventListenerSpy.mock.calls.find((c) => c[0] === 'storage')?.[1] as (
|
||||
e: StorageEvent,
|
||||
) => void;
|
||||
handler(new StorageEvent('storage', { key: 'sb-rotated-auth-token', newValue: 'x' }));
|
||||
|
||||
expect(chromeMock.storage.local.set).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(chromeMock.storage.local.set.mock.calls[0]![0] as Record<string, unknown>)[
|
||||
'readestAccessToken'
|
||||
],
|
||||
).toBe('new-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Runs on `web.readest.com` (and the dev host). Reads Readest's Supabase
|
||||
* session token out of the page's localStorage and copies it into
|
||||
* `chrome.storage.local`, so the extension popup can authenticate to
|
||||
* `/api/send/inbox` without prompting the user for credentials.
|
||||
*
|
||||
* No user data leaves the user's browser — the token is only ever stored in
|
||||
* the extension's own storage area, scoped to the extension.
|
||||
*/
|
||||
|
||||
interface SupabaseAuthValue {
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
function findAccessToken(): string | null {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || !/^sb-.*-auth-token$/.test(key)) continue;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const value = JSON.parse(raw) as SupabaseAuthValue;
|
||||
if (value && typeof value.access_token === 'string' && value.access_token) {
|
||||
return value.access_token;
|
||||
}
|
||||
} catch {
|
||||
// Malformed entries are ignored — Supabase occasionally writes partials.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncToken(): void {
|
||||
const token = findAccessToken();
|
||||
if (token) {
|
||||
chrome.storage.local.set({
|
||||
readestAccessToken: token,
|
||||
readestTokenAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// Token went away (sign-out) — clear so the extension stops showing a
|
||||
// stale "signed in" state.
|
||||
chrome.storage.local.remove(['readestAccessToken', 'readestTokenAt']);
|
||||
}
|
||||
}
|
||||
|
||||
syncToken();
|
||||
|
||||
// Refresh on storage changes too — Supabase rotates the access token a few
|
||||
// times an hour. Polling localStorage isn't free, but a `storage` event
|
||||
// listener fires only when the value actually changes.
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key && /^sb-.*-auth-token$/.test(event.key)) {
|
||||
syncToken();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Content-script entry point for capturing the current page. Injected
|
||||
* on-demand by the service worker via `chrome.scripting.executeScript({
|
||||
* files: ['content/capture.js'] })`.
|
||||
*
|
||||
* Reduced to a thin shell: grab the rendered `outerHTML` and hand it to
|
||||
* the SW on a long-lived Port. The SW runs everything else (Readability
|
||||
* extraction, asset bundling, cover generation, TOC, EPUB build) through
|
||||
* the shared `convertPageToEpub` in `src/services/send/conversion/`, so
|
||||
* an extension-clipped EPUB is byte-identical to a desktop / mobile-
|
||||
* clipped EPUB for the same URL.
|
||||
*
|
||||
* We intentionally do NOT scroll the page to materialise lazy-loaded
|
||||
* images — the user can see the scroll, and on long pages it's visible
|
||||
* and slow. Images that haven't been viewed are typically still
|
||||
* resolvable via `data-src` / `srcset` attributes which `bundleAssets`
|
||||
* already understands, so the cost is small.
|
||||
*/
|
||||
|
||||
import type { PageSnapshot } from '../lib/messages';
|
||||
|
||||
const LOG = '[send-to-readest]';
|
||||
const PORT_NAME = 'send-to-readest:capture';
|
||||
|
||||
function snapshot(): PageSnapshot {
|
||||
const sourceUrl =
|
||||
document.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href || location.href;
|
||||
return {
|
||||
sourceUrl,
|
||||
pageTitle: document.title || sourceUrl,
|
||||
html: document.documentElement.outerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
let port: chrome.runtime.Port;
|
||||
try {
|
||||
port = chrome.runtime.connect({ name: PORT_NAME });
|
||||
} catch (err) {
|
||||
console.warn(LOG, 'failed to open port', err);
|
||||
return;
|
||||
}
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn(LOG, 'port closed early', chrome.runtime.lastError.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
port.postMessage({ snapshot: snapshot() });
|
||||
} catch (err) {
|
||||
console.warn(LOG, 'snapshot threw', err);
|
||||
port.postMessage({
|
||||
snapshot: null,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
port.disconnect();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
installChromeMock,
|
||||
uninstallChromeMock,
|
||||
type ChromeMock,
|
||||
} from '../__test-utils__/chromeMock';
|
||||
import { clearToken, readToken, writeToken } from './auth';
|
||||
|
||||
let chromeMock: ChromeMock;
|
||||
|
||||
beforeEach(() => {
|
||||
chromeMock = installChromeMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstallChromeMock();
|
||||
});
|
||||
|
||||
describe('readToken / writeToken / clearToken', () => {
|
||||
test('returns null when nothing is stored', async () => {
|
||||
const token = await readToken();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
test('round-trips a token with the captured timestamp', async () => {
|
||||
const before = Date.now();
|
||||
await writeToken('abc123');
|
||||
const after = Date.now();
|
||||
const stored = await readToken();
|
||||
expect(stored?.token).toBe('abc123');
|
||||
expect(stored?.capturedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(stored?.capturedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
test('clearToken wipes both keys', async () => {
|
||||
await writeToken('xyz');
|
||||
expect((await readToken())?.token).toBe('xyz');
|
||||
await clearToken();
|
||||
expect(await readToken()).toBeNull();
|
||||
expect(chromeMock.storage.local.remove).toHaveBeenCalledWith([
|
||||
'readestAccessToken',
|
||||
'readestTokenAt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats a non-string stored value as missing', async () => {
|
||||
await chromeMock.storage.local.set({ readestAccessToken: 12345 });
|
||||
expect(await readToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('treats an empty-string stored value as missing', async () => {
|
||||
await chromeMock.storage.local.set({ readestAccessToken: '' });
|
||||
expect(await readToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('coerces a non-numeric capturedAt to 0', async () => {
|
||||
await chromeMock.storage.local.set({
|
||||
readestAccessToken: 'tok',
|
||||
readestTokenAt: 'not-a-number',
|
||||
});
|
||||
const stored = await readToken();
|
||||
expect(stored?.token).toBe('tok');
|
||||
expect(stored?.capturedAt).toBe(0);
|
||||
});
|
||||
|
||||
test('writeToken sets capturedAt to Date.now()', async () => {
|
||||
const fixedNow = 1_700_000_000_000;
|
||||
const spy = vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
|
||||
try {
|
||||
await writeToken('frozen');
|
||||
const stored = await readToken();
|
||||
expect(stored?.capturedAt).toBe(fixedNow);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Auth-token storage helpers. The token is captured by the `auth-bridge`
|
||||
* content script while the user is signed in at web.readest.com and read by
|
||||
* the service worker / popup. We never store the user's password — only the
|
||||
* short-lived Supabase access token Readest hands out.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'readestAccessToken';
|
||||
const TOKEN_AT_KEY = 'readestTokenAt';
|
||||
|
||||
export interface StoredToken {
|
||||
token: string;
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
export async function readToken(): Promise<StoredToken | null> {
|
||||
const result = (await chrome.storage.local.get([TOKEN_KEY, TOKEN_AT_KEY])) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const token = result[TOKEN_KEY];
|
||||
const at = result[TOKEN_AT_KEY];
|
||||
if (typeof token !== 'string' || !token) return null;
|
||||
return { token, capturedAt: typeof at === 'number' ? at : 0 };
|
||||
}
|
||||
|
||||
export async function writeToken(token: string): Promise<void> {
|
||||
await chrome.storage.local.set({
|
||||
[TOKEN_KEY]: token,
|
||||
[TOKEN_AT_KEY]: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearToken(): Promise<void> {
|
||||
await chrome.storage.local.remove([TOKEN_KEY, TOKEN_AT_KEY]);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Key-as-content i18n for the extension shell. Matches the readest-app's
|
||||
* `stubTranslation as _` convention — the English source string IS the
|
||||
* key. Import as `_` at every call site so the surface reads the same
|
||||
* as the main repo:
|
||||
*
|
||||
* import { translate as _ } from '../lib/i18n';
|
||||
* _('Send to Readest')
|
||||
* _('Sent — {count} images could not be fetched.', { count: 3 })
|
||||
*
|
||||
* Translation tables live at `src/i18n/<locale>.json` (locale = short
|
||||
* code from `chrome.i18n.getUILanguage()`). The English bundle is
|
||||
* literally `{}` — fall-through to the key. Non-English bundles are
|
||||
* `{ "<english source>": "<translation>" }`.
|
||||
*
|
||||
* Why not Chrome's native `_locales/messages.json` for these strings?
|
||||
* Chrome's spec restricts message names to `[a-zA-Z0-9_@]+`, no spaces
|
||||
* or punctuation — which makes English-as-key impossible. So we keep
|
||||
* `_locales/` only for the manifest-side translations the store needs
|
||||
* (`name`, `description`, `default_title`) and use this helper for the
|
||||
* runtime UI.
|
||||
*/
|
||||
|
||||
import { bundles as rawBundles } from '../locales/index';
|
||||
|
||||
type Messages = Record<string, string>;
|
||||
|
||||
const SENTINEL = '__STRING_NOT_TRANSLATED__';
|
||||
|
||||
function loadBundle(raw: unknown): Messages {
|
||||
// Drop sentinel entries so a partially-translated bundle falls back
|
||||
// to the English source via `active[source] ?? source` instead of
|
||||
// surfacing the placeholder string to users.
|
||||
const out: Messages = {};
|
||||
for (const [k, v] of Object.entries((raw ?? {}) as Record<string, unknown>)) {
|
||||
if (typeof v !== 'string' || v === SENTINEL || v === '') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// `src/i18n/index.ts` is generated by `pnpm i18n:extract` and lists every
|
||||
// locale in `apps/readest-app/i18n-langs.json`. Run the extractor after
|
||||
// adding or removing a locale to keep this map current.
|
||||
const bundles: Record<string, Messages> = Object.fromEntries(
|
||||
Object.entries(rawBundles).map(([code, raw]) => [code, loadBundle(raw)]),
|
||||
);
|
||||
|
||||
function pickLocale(): string {
|
||||
// `chrome` is undeclared (not just undefined) outside extension
|
||||
// contexts — use `globalThis` to read it safely from anywhere
|
||||
// (vitest, ad-hoc imports, etc).
|
||||
const c = (globalThis as { chrome?: typeof chrome }).chrome;
|
||||
const ui = c?.i18n?.getUILanguage?.() ?? 'en';
|
||||
const short = ui.toLowerCase().split('-')[0] ?? 'en';
|
||||
return bundles[short] ? short : 'en';
|
||||
}
|
||||
|
||||
const active: Messages = bundles[pickLocale()] ?? bundles['en'] ?? {};
|
||||
|
||||
/**
|
||||
* Resolve a key against the active locale bundle, falling back to the
|
||||
* key itself when no translation is registered. `vars` are interpolated
|
||||
* into `{placeholder}` slots in the source string AND in the translated
|
||||
* string, mirroring i18next's `{{var}}` semantics but with single braces
|
||||
* to match the existing readest-app conventions.
|
||||
*/
|
||||
export function translate(source: string, vars?: Record<string, string | number>): string {
|
||||
const translated = active[source] ?? source;
|
||||
if (!vars) return translated;
|
||||
return translated.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in vars ? String(vars[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a subtree and replace text on any element carrying a
|
||||
* `data-i18n` attribute. The attribute value is the English source
|
||||
* string (key-as-content), so the HTML reads naturally without a
|
||||
* separate string-key dictionary.
|
||||
*/
|
||||
export function localizeDom(root: ParentNode = document): void {
|
||||
for (const el of Array.from(root.querySelectorAll<HTMLElement>('[data-i18n]'))) {
|
||||
const source = el.dataset['i18n'];
|
||||
if (source) el.textContent = translate(source);
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll<HTMLElement>('[data-i18n-title]'))) {
|
||||
const source = el.dataset['i18nTitle'];
|
||||
if (source) el.title = translate(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Message protocol between popup, service worker, and content script. Keep
|
||||
* shapes flat and JSON-serializable — `chrome.runtime.sendMessage` can't carry
|
||||
* Blobs, Files, or non-plain objects across boundaries.
|
||||
*
|
||||
* The content script's job has been reduced to a page snapshot: it grabs
|
||||
* the rendered `outerHTML` + canonical URL and hands those off to the SW,
|
||||
* which delegates everything (Readability, asset bundle, cover, TOC, EPUB
|
||||
* build) to the shared `convertPageToEpub` in `src/services/send/conversion/`.
|
||||
* That keeps the extension's EPUB output byte-identical with the desktop /
|
||||
* mobile `/send` clipping paths.
|
||||
*/
|
||||
|
||||
export interface PageSnapshot {
|
||||
/** Canonical URL when present, falling back to `location.href`. */
|
||||
sourceUrl: string;
|
||||
/** `document.title` — used only as a popup display fallback; the
|
||||
* shared converter re-extracts the real title from the captured HTML. */
|
||||
pageTitle: string;
|
||||
/** Full rendered `document.documentElement.outerHTML`. */
|
||||
html: string;
|
||||
}
|
||||
|
||||
export type ClipProgress =
|
||||
| { phase: 'capturing' }
|
||||
| { phase: 'converting' }
|
||||
| { phase: 'uploading' }
|
||||
| { phase: 'done'; missingAssets?: number }
|
||||
| { phase: 'error'; code: ClipErrorCode; message: string };
|
||||
|
||||
export type ClipErrorCode =
|
||||
| 'no-active-tab'
|
||||
| 'restricted-page'
|
||||
| 'not-signed-in'
|
||||
| 'session-expired'
|
||||
| 'inbox-full'
|
||||
| 'capture-failed'
|
||||
| 'no-readable-content'
|
||||
| 'network-error'
|
||||
| 'server-error'
|
||||
| 'unknown';
|
||||
|
||||
/** popup → service worker */
|
||||
export interface ClipRequest {
|
||||
type: 'send-to-readest:clip';
|
||||
tabId: number;
|
||||
}
|
||||
|
||||
/** service worker → popup (broadcast updates) */
|
||||
export interface ClipProgressMessage {
|
||||
type: 'send-to-readest:progress';
|
||||
progress: ClipProgress;
|
||||
}
|
||||
|
||||
/** popup → service worker: get current state on popup (re-)open. */
|
||||
export interface StatusRequest {
|
||||
type: 'send-to-readest:status';
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
signedIn: boolean;
|
||||
inFlight: boolean;
|
||||
lastProgress: ClipProgress | null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "المقالة أكبر من أن يتم إرسالها",
|
||||
"Building EPUB…": "جارٍ إنشاء EPUB…",
|
||||
"Capture script could not start on this page": "لم يتمكن نص الالتقاط من البدء في هذه الصفحة",
|
||||
"Conversion timed out after {seconds}s": "انتهت مهلة التحويل بعد {seconds} ثانية",
|
||||
"Could not inject capture script: {reason}": "تعذر إدخال نص الالتقاط: {reason}",
|
||||
"Could not reach {host} — {reason}": "تعذر الوصول إلى {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "تعذر بدء صفحة المحول: {reason}",
|
||||
"Couldn't read this page": "تعذر قراءة هذه الصفحة",
|
||||
"Inbox is full": "صندوق الوارد ممتلئ",
|
||||
"Loading…": "جارٍ التحميل…",
|
||||
"No active tab": "لا توجد علامة تبويب نشطة",
|
||||
"Offscreen page failed to start": "تعذر بدء صفحة المحول",
|
||||
"Open web.readest.com": "فتح web.readest.com",
|
||||
"Page took too long to read": "استغرقت قراءة الصفحة وقتًا طويلاً",
|
||||
"Reading page…": "جارٍ قراءة الصفحة…",
|
||||
"Send to Readest": "إرسال إلى Readest",
|
||||
"Sending to Readest…": "جارٍ الإرسال إلى Readest…",
|
||||
"Sent — {count} images could not be fetched.": "تم الإرسال — تعذر جلب {count} صورة.",
|
||||
"Sent — 1 image could not be fetched.": "تم الإرسال — تعذر جلب صورة واحدة.",
|
||||
"Sent — it will appear in your library shortly.": "تم الإرسال — ستظهر في مكتبتك قريبًا.",
|
||||
"Server returned {status}": "أعاد الخادم {status}",
|
||||
"Session expired": "انتهت الجلسة",
|
||||
"Sign in at web.readest.com first": "سجّل الدخول أولاً إلى web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "سجّل الدخول إلى Readest لبدء حفظ الصفحات.",
|
||||
"Signed out": "تم تسجيل الخروج",
|
||||
"Starting…": "جارٍ البدء…",
|
||||
"This page cannot be clipped": "لا يمكن حفظ هذه الصفحة",
|
||||
"Unexpected server response": "استجابة خادم غير متوقعة",
|
||||
"Unknown error": "خطأ غير معروف"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "নিবন্ধটি পাঠানোর জন্য খুব বড়",
|
||||
"Building EPUB…": "EPUB তৈরি হচ্ছে…",
|
||||
"Capture script could not start on this page": "ক্যাপচার স্ক্রিপ্ট এই পৃষ্ঠায় শুরু হতে পারেনি",
|
||||
"Conversion timed out after {seconds}s": "{seconds} সেকেন্ড পরে রূপান্তর সময় শেষ হয়েছে",
|
||||
"Could not inject capture script: {reason}": "ক্যাপচার স্ক্রিপ্ট ইনজেক্ট করা যায়নি: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host}-এ পৌঁছানো যায়নি — {reason}",
|
||||
"Could not start the converter page: {reason}": "কনভার্টার পৃষ্ঠা চালু করা যায়নি: {reason}",
|
||||
"Couldn't read this page": "এই পৃষ্ঠাটি পড়া যায়নি",
|
||||
"Inbox is full": "ইনবক্স পূর্ণ",
|
||||
"Loading…": "লোড হচ্ছে…",
|
||||
"No active tab": "কোনো সক্রিয় ট্যাব নেই",
|
||||
"Offscreen page failed to start": "কনভার্টার পৃষ্ঠা শুরু হতে ব্যর্থ",
|
||||
"Open web.readest.com": "web.readest.com খুলুন",
|
||||
"Page took too long to read": "পৃষ্ঠাটি পড়তে অনেক বেশি সময় লেগেছে",
|
||||
"Reading page…": "পৃষ্ঠা পড়া হচ্ছে…",
|
||||
"Send to Readest": "Readest-এ পাঠান",
|
||||
"Sending to Readest…": "Readest-এ পাঠানো হচ্ছে…",
|
||||
"Sent — {count} images could not be fetched.": "পাঠানো হয়েছে — {count}টি ছবি আনা যায়নি।",
|
||||
"Sent — 1 image could not be fetched.": "পাঠানো হয়েছে — ১টি ছবি আনা যায়নি।",
|
||||
"Sent — it will appear in your library shortly.": "পাঠানো হয়েছে — শীঘ্রই আপনার লাইব্রেরিতে দেখা যাবে।",
|
||||
"Server returned {status}": "সার্ভার {status} ফেরত দিয়েছে",
|
||||
"Session expired": "সেশনের মেয়াদ শেষ হয়েছে",
|
||||
"Sign in at web.readest.com first": "প্রথমে web.readest.com-এ সাইন ইন করুন",
|
||||
"Sign in to Readest to start clipping pages.": "পৃষ্ঠা সংরক্ষণ শুরু করতে Readest-এ সাইন ইন করুন।",
|
||||
"Signed out": "সাইন আউট হয়েছে",
|
||||
"Starting…": "শুরু হচ্ছে…",
|
||||
"This page cannot be clipped": "এই পৃষ্ঠাটি সংরক্ষণ করা যাবে না",
|
||||
"Unexpected server response": "অপ্রত্যাশিত সার্ভার প্রতিক্রিয়া",
|
||||
"Unknown error": "অজানা ত্রুটি"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "རྩོམ་ཡིག་འདི་ནི་གཏོང་ཐུབ་པ་ལས་ཆེ་དྲགས་འདུག",
|
||||
"Building EPUB…": "EPUB བཟོ་བཞིན་པ…",
|
||||
"Capture script could not start on this page": "ཤོག་འདིའི་ནང་འཛིན་སྐུལ་འདྲེན་འགོ་འཛུགས་མ་ཐུབ།",
|
||||
"Conversion timed out after {seconds}s": "བསྒྱུར་བཅོས་སྐར་མ་ {seconds} རྗེས་ལ་དུས་ཡོལ་སོང་།",
|
||||
"Could not inject capture script: {reason}": "འཛིན་སྐུལ་འདྲེན་འཇུག་མ་ཐུབ། {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} ལ་མཐོང་ཐུབ་མ་སོང་། {reason}",
|
||||
"Could not start the converter page: {reason}": "བསྒྱུར་བྱེད་ཤོག་འགོ་འཛུགས་མ་ཐུབ། {reason}",
|
||||
"Couldn't read this page": "ཤོག་འདི་ཀློག་མ་ཐུབ།",
|
||||
"Inbox is full": "ནང་འཇུག་སྦུག་ཁ་གང་འདུག",
|
||||
"Loading…": "འཇུག་བཞིན་པ…",
|
||||
"No active tab": "བྱེད་སྒོ་ཡོད་པའི་ཤོག་ལྡེབ་མེད།",
|
||||
"Offscreen page failed to start": "བསྒྱུར་བྱེད་ཤོག་འགོ་འཛུགས་མ་ཐུབ།",
|
||||
"Open web.readest.com": "web.readest.com ཁ་ཕྱེ།",
|
||||
"Page took too long to read": "ཤོག་ཀློག་པར་དུས་ཚོད་མང་དྲགས་སོང་།",
|
||||
"Reading page…": "ཤོག་ཀློག་བཞིན་པ…",
|
||||
"Send to Readest": "Readest ལ་གཏོང་།",
|
||||
"Sending to Readest…": "Readest ལ་གཏོང་བཞིན་པ…",
|
||||
"Sent — {count} images could not be fetched.": "བཏང་ཟིན། {count} པར་རིས་འདྲེན་མ་ཐུབ།",
|
||||
"Sent — 1 image could not be fetched.": "བཏང་ཟིན། པར་རིས་ 1 འདྲེན་མ་ཐུབ།",
|
||||
"Sent — it will appear in your library shortly.": "བཏང་ཟིན། མི་རིང་བར་ཁྱེད་ཀྱི་དཔེ་མཛོད་དུ་འབྱུང་ངེས།",
|
||||
"Server returned {status}": "ཞབས་ཞུ་བས་ {status} ཕྱིར་སྤྲོད་སོང་།",
|
||||
"Session expired": "གྲོས་མོལ་གྱི་དུས་ཚོད་རྫོགས་སོང་།",
|
||||
"Sign in at web.readest.com first": "ཐོག་མར་ web.readest.com ལ་ནང་འཇུག་གནང་རོགས།",
|
||||
"Sign in to Readest to start clipping pages.": "ཤོག་ཉར་ཚགས་འགོ་འཛུགས་ཕྱིར་ Readest ལ་ནང་འཇུག་གནང་རོགས།",
|
||||
"Signed out": "ཕྱིར་འཐེན་བྱས་ཟིན།",
|
||||
"Starting…": "འགོ་འཛུགས་བཞིན་པ…",
|
||||
"This page cannot be clipped": "ཤོག་འདི་ཉར་ཚགས་མི་ཐུབ།",
|
||||
"Unexpected server response": "བློར་མ་ཤར་བའི་ཞབས་ཞུའི་ལན་འདེབས།",
|
||||
"Unknown error": "ངོ་མ་ཤེས་པའི་སྐྱོན།"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artikel ist zu groß zum Senden",
|
||||
"Building EPUB…": "EPUB wird erstellt…",
|
||||
"Capture script could not start on this page": "Erfassungsskript konnte auf dieser Seite nicht gestartet werden",
|
||||
"Conversion timed out after {seconds}s": "Konvertierung nach {seconds} s abgelaufen",
|
||||
"Could not inject capture script: {reason}": "Erfassungsskript konnte nicht eingefügt werden: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} konnte nicht erreicht werden — {reason}",
|
||||
"Could not start the converter page: {reason}": "Konverterseite konnte nicht gestartet werden: {reason}",
|
||||
"Couldn't read this page": "Diese Seite konnte nicht gelesen werden",
|
||||
"Inbox is full": "Posteingang ist voll",
|
||||
"Loading…": "Wird geladen…",
|
||||
"No active tab": "Kein aktiver Tab",
|
||||
"Offscreen page failed to start": "Konverterseite konnte nicht gestartet werden",
|
||||
"Open web.readest.com": "web.readest.com öffnen",
|
||||
"Page took too long to read": "Seite hat zu lange zum Lesen gebraucht",
|
||||
"Reading page…": "Seite wird gelesen…",
|
||||
"Send to Readest": "An Readest senden",
|
||||
"Sending to Readest…": "Wird an Readest gesendet…",
|
||||
"Sent — {count} images could not be fetched.": "Gesendet — {count} Bilder konnten nicht abgerufen werden.",
|
||||
"Sent — 1 image could not be fetched.": "Gesendet — 1 Bild konnte nicht abgerufen werden.",
|
||||
"Sent — it will appear in your library shortly.": "Gesendet — der Artikel erscheint in Kürze in Ihrer Bibliothek.",
|
||||
"Server returned {status}": "Server antwortete mit {status}",
|
||||
"Session expired": "Sitzung abgelaufen",
|
||||
"Sign in at web.readest.com first": "Zuerst bei web.readest.com anmelden",
|
||||
"Sign in to Readest to start clipping pages.": "Bei Readest anmelden, um Seiten zu speichern.",
|
||||
"Signed out": "Abgemeldet",
|
||||
"Starting…": "Wird gestartet…",
|
||||
"This page cannot be clipped": "Diese Seite kann nicht gespeichert werden",
|
||||
"Unexpected server response": "Unerwartete Serverantwort",
|
||||
"Unknown error": "Unbekannter Fehler"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Το άρθρο είναι πολύ μεγάλο για να σταλεί",
|
||||
"Building EPUB…": "Δημιουργία EPUB…",
|
||||
"Capture script could not start on this page": "Το σενάριο λήψης δεν μπόρεσε να ξεκινήσει σε αυτή τη σελίδα",
|
||||
"Conversion timed out after {seconds}s": "Η μετατροπή έληξε μετά από {seconds} δευτ.",
|
||||
"Could not inject capture script: {reason}": "Αδυναμία εισαγωγής σεναρίου λήψης: {reason}",
|
||||
"Could not reach {host} — {reason}": "Δεν ήταν δυνατή η σύνδεση με {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Δεν ήταν δυνατή η εκκίνηση της σελίδας μετατροπής: {reason}",
|
||||
"Couldn't read this page": "Δεν ήταν δυνατή η ανάγνωση της σελίδας",
|
||||
"Inbox is full": "Τα εισερχόμενα είναι γεμάτα",
|
||||
"Loading…": "Φόρτωση…",
|
||||
"No active tab": "Καμία ενεργή καρτέλα",
|
||||
"Offscreen page failed to start": "Η σελίδα μετατροπής δεν μπόρεσε να ξεκινήσει",
|
||||
"Open web.readest.com": "Άνοιγμα του web.readest.com",
|
||||
"Page took too long to read": "Η σελίδα χρειάστηκε πολύ χρόνο για να διαβαστεί",
|
||||
"Reading page…": "Ανάγνωση σελίδας…",
|
||||
"Send to Readest": "Αποστολή στο Readest",
|
||||
"Sending to Readest…": "Αποστολή στο Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Στάλθηκε — δεν ήταν δυνατή η λήψη {count} εικόνων.",
|
||||
"Sent — 1 image could not be fetched.": "Στάλθηκε — δεν ήταν δυνατή η λήψη 1 εικόνας.",
|
||||
"Sent — it will appear in your library shortly.": "Στάλθηκε — θα εμφανιστεί σύντομα στη βιβλιοθήκη σας.",
|
||||
"Server returned {status}": "Ο διακομιστής επέστρεψε {status}",
|
||||
"Session expired": "Η συνεδρία έληξε",
|
||||
"Sign in at web.readest.com first": "Συνδεθείτε πρώτα στο web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Συνδεθείτε στο Readest για να αρχίσετε να αποθηκεύετε σελίδες.",
|
||||
"Signed out": "Αποσυνδεδεμένος",
|
||||
"Starting…": "Εκκίνηση…",
|
||||
"This page cannot be clipped": "Αυτή η σελίδα δεν μπορεί να αποθηκευτεί",
|
||||
"Unexpected server response": "Μη αναμενόμενη απόκριση διακομιστή",
|
||||
"Unknown error": "Άγνωστο σφάλμα"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "El artículo es demasiado grande para enviarlo",
|
||||
"Building EPUB…": "Creando EPUB…",
|
||||
"Capture script could not start on this page": "El script de captura no pudo iniciarse en esta página",
|
||||
"Conversion timed out after {seconds}s": "La conversión expiró tras {seconds} s",
|
||||
"Could not inject capture script: {reason}": "No se pudo inyectar el script de captura: {reason}",
|
||||
"Could not reach {host} — {reason}": "No se pudo contactar con {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "No se pudo iniciar la página del conversor: {reason}",
|
||||
"Couldn't read this page": "No se pudo leer esta página",
|
||||
"Inbox is full": "La bandeja de entrada está llena",
|
||||
"Loading…": "Cargando…",
|
||||
"No active tab": "Ninguna pestaña activa",
|
||||
"Offscreen page failed to start": "La página del conversor no pudo iniciarse",
|
||||
"Open web.readest.com": "Abrir web.readest.com",
|
||||
"Page took too long to read": "La página tardó demasiado en leerse",
|
||||
"Reading page…": "Leyendo la página…",
|
||||
"Send to Readest": "Enviar a Readest",
|
||||
"Sending to Readest…": "Enviando a Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Enviado — {count} imágenes no se pudieron descargar.",
|
||||
"Sent — 1 image could not be fetched.": "Enviado — 1 imagen no se pudo descargar.",
|
||||
"Sent — it will appear in your library shortly.": "Enviado — aparecerá en tu biblioteca en breve.",
|
||||
"Server returned {status}": "El servidor devolvió {status}",
|
||||
"Session expired": "Sesión caducada",
|
||||
"Sign in at web.readest.com first": "Inicia sesión primero en web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Inicia sesión en Readest para empezar a guardar páginas.",
|
||||
"Signed out": "Sesión cerrada",
|
||||
"Starting…": "Iniciando…",
|
||||
"This page cannot be clipped": "Esta página no se puede guardar",
|
||||
"Unexpected server response": "Respuesta inesperada del servidor",
|
||||
"Unknown error": "Error desconocido"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "مقاله برای ارسال خیلی بزرگ است",
|
||||
"Building EPUB…": "در حال ساخت EPUB…",
|
||||
"Capture script could not start on this page": "اسکریپت ضبط در این صفحه شروع نشد",
|
||||
"Conversion timed out after {seconds}s": "تبدیل پس از {seconds} ثانیه به پایان رسید",
|
||||
"Could not inject capture script: {reason}": "اسکریپت ضبط تزریق نشد: {reason}",
|
||||
"Could not reach {host} — {reason}": "اتصال به {host} برقرار نشد — {reason}",
|
||||
"Could not start the converter page: {reason}": "صفحه مبدل شروع نشد: {reason}",
|
||||
"Couldn't read this page": "این صفحه قابل خواندن نیست",
|
||||
"Inbox is full": "صندوق ورودی پر است",
|
||||
"Loading…": "در حال بارگذاری…",
|
||||
"No active tab": "هیچ زبانه فعالی وجود ندارد",
|
||||
"Offscreen page failed to start": "صفحه مبدل شروع نشد",
|
||||
"Open web.readest.com": "باز کردن web.readest.com",
|
||||
"Page took too long to read": "خواندن صفحه بیش از حد طول کشید",
|
||||
"Reading page…": "در حال خواندن صفحه…",
|
||||
"Send to Readest": "ارسال به Readest",
|
||||
"Sending to Readest…": "در حال ارسال به Readest…",
|
||||
"Sent — {count} images could not be fetched.": "ارسال شد — {count} تصویر دریافت نشد.",
|
||||
"Sent — 1 image could not be fetched.": "ارسال شد — ۱ تصویر دریافت نشد.",
|
||||
"Sent — it will appear in your library shortly.": "ارسال شد — بهزودی در کتابخانه شما ظاهر میشود.",
|
||||
"Server returned {status}": "سرور {status} برگرداند",
|
||||
"Session expired": "جلسه منقضی شد",
|
||||
"Sign in at web.readest.com first": "ابتدا در web.readest.com وارد شوید",
|
||||
"Sign in to Readest to start clipping pages.": "برای ذخیره صفحات، در Readest وارد شوید.",
|
||||
"Signed out": "خارج شده",
|
||||
"Starting…": "در حال شروع…",
|
||||
"This page cannot be clipped": "این صفحه قابل ذخیره نیست",
|
||||
"Unexpected server response": "پاسخ غیرمنتظره سرور",
|
||||
"Unknown error": "خطای نامشخص"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "L'article est trop volumineux pour être envoyé",
|
||||
"Building EPUB…": "Création de l’EPUB…",
|
||||
"Capture script could not start on this page": "Le script de capture n’a pas pu démarrer sur cette page",
|
||||
"Conversion timed out after {seconds}s": "Conversion expirée après {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Impossible d’injecter le script de capture : {reason}",
|
||||
"Could not reach {host} — {reason}": "Impossible d’atteindre {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Impossible de démarrer la page du convertisseur : {reason}",
|
||||
"Couldn't read this page": "Impossible de lire cette page",
|
||||
"Inbox is full": "La boîte de réception est pleine",
|
||||
"Loading…": "Chargement…",
|
||||
"No active tab": "Aucun onglet actif",
|
||||
"Offscreen page failed to start": "Le démarrage de la page du convertisseur a échoué",
|
||||
"Open web.readest.com": "Ouvrir web.readest.com",
|
||||
"Page took too long to read": "La page a mis trop de temps à être lue",
|
||||
"Reading page…": "Lecture de la page…",
|
||||
"Send to Readest": "Envoyer vers Readest",
|
||||
"Sending to Readest…": "Envoi vers Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Envoyé — {count} images n’ont pas pu être récupérées.",
|
||||
"Sent — 1 image could not be fetched.": "Envoyé — 1 image n’a pas pu être récupérée.",
|
||||
"Sent — it will appear in your library shortly.": "Envoyé — l’article apparaîtra bientôt dans votre bibliothèque.",
|
||||
"Server returned {status}": "Le serveur a renvoyé {status}",
|
||||
"Session expired": "Session expirée",
|
||||
"Sign in at web.readest.com first": "Connectez-vous d’abord sur web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Connectez-vous à Readest pour commencer à enregistrer des pages.",
|
||||
"Signed out": "Déconnecté",
|
||||
"Starting…": "Démarrage…",
|
||||
"This page cannot be clipped": "Cette page ne peut pas être enregistrée",
|
||||
"Unexpected server response": "Réponse inattendue du serveur",
|
||||
"Unknown error": "Erreur inconnue"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "המאמר גדול מדי לשליחה",
|
||||
"Building EPUB…": "יצירת EPUB…",
|
||||
"Capture script could not start on this page": "הסקריפט ללכידה לא הצליח להתחיל בדף זה",
|
||||
"Conversion timed out after {seconds}s": "ההמרה הסתיימה לאחר {seconds} שניות",
|
||||
"Could not inject capture script: {reason}": "לא ניתן להזריק את סקריפט הלכידה: {reason}",
|
||||
"Could not reach {host} — {reason}": "לא ניתן להגיע אל {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "לא ניתן להפעיל את דף הממיר: {reason}",
|
||||
"Couldn't read this page": "לא ניתן לקרוא דף זה",
|
||||
"Inbox is full": "תיבת הדואר הנכנס מלאה",
|
||||
"Loading…": "טוען…",
|
||||
"No active tab": "אין כרטיסייה פעילה",
|
||||
"Offscreen page failed to start": "דף הממיר לא הצליח להתחיל",
|
||||
"Open web.readest.com": "פתח את web.readest.com",
|
||||
"Page took too long to read": "קריאת הדף ארכה זמן רב מדי",
|
||||
"Reading page…": "קורא את הדף…",
|
||||
"Send to Readest": "שלח ל-Readest",
|
||||
"Sending to Readest…": "שולח ל-Readest…",
|
||||
"Sent — {count} images could not be fetched.": "נשלח — לא ניתן היה לאחזר {count} תמונות.",
|
||||
"Sent — 1 image could not be fetched.": "נשלח — לא ניתן היה לאחזר תמונה אחת.",
|
||||
"Sent — it will appear in your library shortly.": "נשלח — יופיע בקרוב בספרייה שלך.",
|
||||
"Server returned {status}": "השרת החזיר {status}",
|
||||
"Session expired": "תוקף ההפעלה פג",
|
||||
"Sign in at web.readest.com first": "התחבר תחילה ל-web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "התחבר ל-Readest כדי להתחיל לשמור דפים.",
|
||||
"Signed out": "מנותק",
|
||||
"Starting…": "מתחיל…",
|
||||
"This page cannot be clipped": "לא ניתן לשמור דף זה",
|
||||
"Unexpected server response": "תגובת שרת בלתי צפויה",
|
||||
"Unknown error": "שגיאה לא ידועה"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "लेख भेजने के लिए बहुत बड़ा है",
|
||||
"Building EPUB…": "EPUB बनाया जा रहा है…",
|
||||
"Capture script could not start on this page": "इस पृष्ठ पर कैप्चर स्क्रिप्ट प्रारंभ नहीं हो सकी",
|
||||
"Conversion timed out after {seconds}s": "{seconds} सेकंड के बाद रूपांतरण समाप्त हो गया",
|
||||
"Could not inject capture script: {reason}": "कैप्चर स्क्रिप्ट इंजेक्ट नहीं की जा सकी: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} तक नहीं पहुँच सकते — {reason}",
|
||||
"Could not start the converter page: {reason}": "कनवर्टर पृष्ठ प्रारंभ नहीं हो सका: {reason}",
|
||||
"Couldn't read this page": "इस पृष्ठ को पढ़ा नहीं जा सका",
|
||||
"Inbox is full": "इनबॉक्स भरा हुआ है",
|
||||
"Loading…": "लोड हो रहा है…",
|
||||
"No active tab": "कोई सक्रिय टैब नहीं",
|
||||
"Offscreen page failed to start": "कनवर्टर पृष्ठ प्रारंभ नहीं हो सका",
|
||||
"Open web.readest.com": "web.readest.com खोलें",
|
||||
"Page took too long to read": "पृष्ठ पढ़ने में बहुत समय लगा",
|
||||
"Reading page…": "पृष्ठ पढ़ा जा रहा है…",
|
||||
"Send to Readest": "Readest पर भेजें",
|
||||
"Sending to Readest…": "Readest पर भेजा जा रहा है…",
|
||||
"Sent — {count} images could not be fetched.": "भेजा गया — {count} छवियाँ प्राप्त नहीं की जा सकीं।",
|
||||
"Sent — 1 image could not be fetched.": "भेजा गया — 1 छवि प्राप्त नहीं की जा सकी।",
|
||||
"Sent — it will appear in your library shortly.": "भेजा गया — यह जल्द ही आपकी लाइब्रेरी में दिखाई देगा।",
|
||||
"Server returned {status}": "सर्वर ने {status} लौटाया",
|
||||
"Session expired": "सत्र समाप्त हो गया",
|
||||
"Sign in at web.readest.com first": "पहले web.readest.com पर साइन इन करें",
|
||||
"Sign in to Readest to start clipping pages.": "पृष्ठ सहेजना शुरू करने के लिए Readest में साइन इन करें।",
|
||||
"Signed out": "साइन आउट किया गया",
|
||||
"Starting…": "प्रारंभ हो रहा है…",
|
||||
"This page cannot be clipped": "इस पृष्ठ को सहेजा नहीं जा सकता",
|
||||
"Unexpected server response": "अप्रत्याशित सर्वर प्रतिक्रिया",
|
||||
"Unknown error": "अज्ञात त्रुटि"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "A cikk túl nagy a küldéshez",
|
||||
"Building EPUB…": "EPUB készítése…",
|
||||
"Capture script could not start on this page": "A rögzítő szkript nem tudott elindulni ezen az oldalon",
|
||||
"Conversion timed out after {seconds}s": "A konverzió időtúllépéssel zárult {seconds} s után",
|
||||
"Could not inject capture script: {reason}": "A rögzítő szkript nem volt befecskendezhető: {reason}",
|
||||
"Could not reach {host} — {reason}": "Nem sikerült elérni a következőt: {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Az átalakító oldal nem indítható el: {reason}",
|
||||
"Couldn't read this page": "Nem sikerült elolvasni ezt az oldalt",
|
||||
"Inbox is full": "A beérkező mappa megtelt",
|
||||
"Loading…": "Betöltés…",
|
||||
"No active tab": "Nincs aktív lap",
|
||||
"Offscreen page failed to start": "Az átalakító oldal indítása nem sikerült",
|
||||
"Open web.readest.com": "web.readest.com megnyitása",
|
||||
"Page took too long to read": "Az oldal beolvasása túl sokáig tartott",
|
||||
"Reading page…": "Oldal beolvasása…",
|
||||
"Send to Readest": "Küldés a Readestbe",
|
||||
"Sending to Readest…": "Küldés a Readestbe…",
|
||||
"Sent — {count} images could not be fetched.": "Elküldve — {count} kép nem volt letölthető.",
|
||||
"Sent — 1 image could not be fetched.": "Elküldve — 1 kép nem volt letölthető.",
|
||||
"Sent — it will appear in your library shortly.": "Elküldve — hamarosan megjelenik a könyvtáradban.",
|
||||
"Server returned {status}": "A kiszolgáló {status} választ adott",
|
||||
"Session expired": "A munkamenet lejárt",
|
||||
"Sign in at web.readest.com first": "Jelentkezzen be előbb a web.readest.com oldalon",
|
||||
"Sign in to Readest to start clipping pages.": "Jelentkezzen be a Readestbe az oldalmentések megkezdéséhez.",
|
||||
"Signed out": "Kijelentkezve",
|
||||
"Starting…": "Indítás…",
|
||||
"This page cannot be clipped": "Ez az oldal nem menthető",
|
||||
"Unexpected server response": "Váratlan kiszolgálóválasz",
|
||||
"Unknown error": "Ismeretlen hiba"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artikel terlalu besar untuk dikirim",
|
||||
"Building EPUB…": "Membangun EPUB…",
|
||||
"Capture script could not start on this page": "Skrip pengambilan tidak dapat dimulai di halaman ini",
|
||||
"Conversion timed out after {seconds}s": "Konversi habis waktu setelah {seconds} dtk",
|
||||
"Could not inject capture script: {reason}": "Tidak dapat menyuntikkan skrip pengambilan: {reason}",
|
||||
"Could not reach {host} — {reason}": "Tidak dapat menjangkau {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Tidak dapat memulai halaman pengonversi: {reason}",
|
||||
"Couldn't read this page": "Tidak dapat membaca halaman ini",
|
||||
"Inbox is full": "Kotak masuk penuh",
|
||||
"Loading…": "Memuat…",
|
||||
"No active tab": "Tidak ada tab aktif",
|
||||
"Offscreen page failed to start": "Halaman pengonversi gagal dimulai",
|
||||
"Open web.readest.com": "Buka web.readest.com",
|
||||
"Page took too long to read": "Pembacaan halaman memakan waktu terlalu lama",
|
||||
"Reading page…": "Membaca halaman…",
|
||||
"Send to Readest": "Kirim ke Readest",
|
||||
"Sending to Readest…": "Mengirim ke Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Terkirim — {count} gambar tidak dapat diunduh.",
|
||||
"Sent — 1 image could not be fetched.": "Terkirim — 1 gambar tidak dapat diunduh.",
|
||||
"Sent — it will appear in your library shortly.": "Terkirim — segera muncul di perpustakaan Anda.",
|
||||
"Server returned {status}": "Server mengembalikan {status}",
|
||||
"Session expired": "Sesi kedaluwarsa",
|
||||
"Sign in at web.readest.com first": "Masuk dulu ke web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Masuk ke Readest untuk mulai menyimpan halaman.",
|
||||
"Signed out": "Keluar",
|
||||
"Starting…": "Memulai…",
|
||||
"This page cannot be clipped": "Halaman ini tidak dapat disimpan",
|
||||
"Unexpected server response": "Respons server tidak terduga",
|
||||
"Unknown error": "Kesalahan tidak diketahui"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// AUTO-GENERATED by `pnpm i18n:extract`. Do not edit by hand —
|
||||
// re-run the extractor whenever a locale is added or removed in
|
||||
// `apps/readest-app/i18n-langs.json`. The runtime in
|
||||
// `src/lib/i18n.ts` reads the bundle map exported from this file.
|
||||
|
||||
type Messages = Record<string, string>;
|
||||
|
||||
import arMessages from './ar.json';
|
||||
import bnMessages from './bn.json';
|
||||
import boMessages from './bo.json';
|
||||
import deMessages from './de.json';
|
||||
import elMessages from './el.json';
|
||||
import enMessages from './en.json';
|
||||
import esMessages from './es.json';
|
||||
import faMessages from './fa.json';
|
||||
import frMessages from './fr.json';
|
||||
import heMessages from './he.json';
|
||||
import hiMessages from './hi.json';
|
||||
import huMessages from './hu.json';
|
||||
import idMessages from './id.json';
|
||||
import itMessages from './it.json';
|
||||
import jaMessages from './ja.json';
|
||||
import koMessages from './ko.json';
|
||||
import msMessages from './ms.json';
|
||||
import nlMessages from './nl.json';
|
||||
import plMessages from './pl.json';
|
||||
import ptMessages from './pt.json';
|
||||
import ptBRMessages from './pt-BR.json';
|
||||
import roMessages from './ro.json';
|
||||
import ruMessages from './ru.json';
|
||||
import siMessages from './si.json';
|
||||
import slMessages from './sl.json';
|
||||
import svMessages from './sv.json';
|
||||
import taMessages from './ta.json';
|
||||
import thMessages from './th.json';
|
||||
import trMessages from './tr.json';
|
||||
import ukMessages from './uk.json';
|
||||
import uzMessages from './uz.json';
|
||||
import viMessages from './vi.json';
|
||||
import zhCNMessages from './zh-CN.json';
|
||||
import zhTWMessages from './zh-TW.json';
|
||||
|
||||
export const bundles: Record<string, Messages> = {
|
||||
ar: arMessages as Messages,
|
||||
bn: bnMessages as Messages,
|
||||
bo: boMessages as Messages,
|
||||
de: deMessages as Messages,
|
||||
el: elMessages as Messages,
|
||||
en: enMessages as Messages,
|
||||
es: esMessages as Messages,
|
||||
fa: faMessages as Messages,
|
||||
fr: frMessages as Messages,
|
||||
he: heMessages as Messages,
|
||||
hi: hiMessages as Messages,
|
||||
hu: huMessages as Messages,
|
||||
id: idMessages as Messages,
|
||||
it: itMessages as Messages,
|
||||
ja: jaMessages as Messages,
|
||||
ko: koMessages as Messages,
|
||||
ms: msMessages as Messages,
|
||||
nl: nlMessages as Messages,
|
||||
pl: plMessages as Messages,
|
||||
pt: ptMessages as Messages,
|
||||
'pt-BR': ptBRMessages as Messages,
|
||||
ro: roMessages as Messages,
|
||||
ru: ruMessages as Messages,
|
||||
si: siMessages as Messages,
|
||||
sl: slMessages as Messages,
|
||||
sv: svMessages as Messages,
|
||||
ta: taMessages as Messages,
|
||||
th: thMessages as Messages,
|
||||
tr: trMessages as Messages,
|
||||
uk: ukMessages as Messages,
|
||||
uz: uzMessages as Messages,
|
||||
vi: viMessages as Messages,
|
||||
'zh-CN': zhCNMessages as Messages,
|
||||
'zh-TW': zhTWMessages as Messages,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "L'articolo è troppo grande per essere inviato",
|
||||
"Building EPUB…": "Creazione dell’EPUB…",
|
||||
"Capture script could not start on this page": "Lo script di acquisizione non è riuscito ad avviarsi su questa pagina",
|
||||
"Conversion timed out after {seconds}s": "Conversione scaduta dopo {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Impossibile iniettare lo script di acquisizione: {reason}",
|
||||
"Could not reach {host} — {reason}": "Impossibile contattare {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Impossibile avviare la pagina del convertitore: {reason}",
|
||||
"Couldn't read this page": "Impossibile leggere questa pagina",
|
||||
"Inbox is full": "La posta in arrivo è piena",
|
||||
"Loading…": "Caricamento…",
|
||||
"No active tab": "Nessuna scheda attiva",
|
||||
"Offscreen page failed to start": "Avvio della pagina del convertitore non riuscito",
|
||||
"Open web.readest.com": "Apri web.readest.com",
|
||||
"Page took too long to read": "La pagina ha impiegato troppo tempo per essere letta",
|
||||
"Reading page…": "Lettura della pagina…",
|
||||
"Send to Readest": "Invia a Readest",
|
||||
"Sending to Readest…": "Invio a Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Inviato — impossibile recuperare {count} immagini.",
|
||||
"Sent — 1 image could not be fetched.": "Inviato — impossibile recuperare 1 immagine.",
|
||||
"Sent — it will appear in your library shortly.": "Inviato — apparirà a breve nella tua libreria.",
|
||||
"Server returned {status}": "Il server ha restituito {status}",
|
||||
"Session expired": "Sessione scaduta",
|
||||
"Sign in at web.readest.com first": "Accedi prima a web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Accedi a Readest per iniziare a salvare le pagine.",
|
||||
"Signed out": "Disconnesso",
|
||||
"Starting…": "Avvio…",
|
||||
"This page cannot be clipped": "Questa pagina non può essere salvata",
|
||||
"Unexpected server response": "Risposta del server imprevista",
|
||||
"Unknown error": "Errore sconosciuto"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "記事のサイズが大きすぎて送信できません",
|
||||
"Building EPUB…": "EPUB を作成中…",
|
||||
"Capture script could not start on this page": "このページで取得スクリプトを開始できませんでした",
|
||||
"Conversion timed out after {seconds}s": "変換が {seconds} 秒後にタイムアウトしました",
|
||||
"Could not inject capture script: {reason}": "取得スクリプトを挿入できませんでした: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} に接続できませんでした — {reason}",
|
||||
"Could not start the converter page: {reason}": "変換ページを開始できませんでした: {reason}",
|
||||
"Couldn't read this page": "このページを読み取れませんでした",
|
||||
"Inbox is full": "受信トレイがいっぱいです",
|
||||
"Loading…": "読み込み中…",
|
||||
"No active tab": "アクティブなタブがありません",
|
||||
"Offscreen page failed to start": "オフスクリーンページの起動に失敗しました",
|
||||
"Open web.readest.com": "web.readest.com を開く",
|
||||
"Page took too long to read": "ページの読み込みに時間がかかりすぎました",
|
||||
"Reading page…": "ページを読み取り中…",
|
||||
"Send to Readest": "Readest に送信",
|
||||
"Sending to Readest…": "Readest に送信中…",
|
||||
"Sent — {count} images could not be fetched.": "送信完了 — {count} 件の画像を取得できませんでした。",
|
||||
"Sent — 1 image could not be fetched.": "送信完了 — 1 件の画像を取得できませんでした。",
|
||||
"Sent — it will appear in your library shortly.": "送信完了 — まもなくライブラリに表示されます。",
|
||||
"Server returned {status}": "サーバーが {status} を返しました",
|
||||
"Session expired": "セッションの有効期限が切れました",
|
||||
"Sign in at web.readest.com first": "まず web.readest.com にサインインしてください",
|
||||
"Sign in to Readest to start clipping pages.": "ページをクリップするには Readest にサインインしてください。",
|
||||
"Signed out": "サインアウト中",
|
||||
"Starting…": "開始中…",
|
||||
"This page cannot be clipped": "このページはクリップできません",
|
||||
"Unexpected server response": "予期しないサーバー応答",
|
||||
"Unknown error": "不明なエラー"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "문서가 너무 커서 보낼 수 없습니다",
|
||||
"Building EPUB…": "EPUB 생성 중…",
|
||||
"Capture script could not start on this page": "이 페이지에서 캡처 스크립트를 시작할 수 없습니다",
|
||||
"Conversion timed out after {seconds}s": "변환이 {seconds}초 후 시간 초과되었습니다",
|
||||
"Could not inject capture script: {reason}": "캡처 스크립트를 삽입할 수 없습니다: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host}에 연결할 수 없습니다 — {reason}",
|
||||
"Could not start the converter page: {reason}": "변환기 페이지를 시작할 수 없습니다: {reason}",
|
||||
"Couldn't read this page": "이 페이지를 읽을 수 없습니다",
|
||||
"Inbox is full": "받은편지함이 가득 찼습니다",
|
||||
"Loading…": "로드 중…",
|
||||
"No active tab": "활성 탭이 없습니다",
|
||||
"Offscreen page failed to start": "오프스크린 페이지를 시작하지 못했습니다",
|
||||
"Open web.readest.com": "web.readest.com 열기",
|
||||
"Page took too long to read": "페이지 읽기에 시간이 너무 오래 걸렸습니다",
|
||||
"Reading page…": "페이지 읽는 중…",
|
||||
"Send to Readest": "Readest로 보내기",
|
||||
"Sending to Readest…": "Readest로 전송 중…",
|
||||
"Sent — {count} images could not be fetched.": "전송 완료 — 이미지 {count}개를 가져올 수 없습니다.",
|
||||
"Sent — 1 image could not be fetched.": "전송 완료 — 이미지 1개를 가져올 수 없습니다.",
|
||||
"Sent — it will appear in your library shortly.": "전송 완료 — 곧 라이브러리에 표시됩니다.",
|
||||
"Server returned {status}": "서버에서 {status}를 반환했습니다",
|
||||
"Session expired": "세션이 만료되었습니다",
|
||||
"Sign in at web.readest.com first": "먼저 web.readest.com에 로그인하세요",
|
||||
"Sign in to Readest to start clipping pages.": "페이지를 저장하려면 Readest에 로그인하세요.",
|
||||
"Signed out": "로그아웃됨",
|
||||
"Starting…": "시작 중…",
|
||||
"This page cannot be clipped": "이 페이지는 저장할 수 없습니다",
|
||||
"Unexpected server response": "예기치 않은 서버 응답",
|
||||
"Unknown error": "알 수 없는 오류"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artikel terlalu besar untuk dihantar",
|
||||
"Building EPUB…": "Membina EPUB…",
|
||||
"Capture script could not start on this page": "Skrip tangkap tidak dapat dimulakan pada halaman ini",
|
||||
"Conversion timed out after {seconds}s": "Penukaran tamat masa selepas {seconds} saat",
|
||||
"Could not inject capture script: {reason}": "Tidak dapat menyuntik skrip tangkap: {reason}",
|
||||
"Could not reach {host} — {reason}": "Tidak dapat mencapai {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Tidak dapat memulakan halaman penukar: {reason}",
|
||||
"Couldn't read this page": "Tidak dapat membaca halaman ini",
|
||||
"Inbox is full": "Peti masuk penuh",
|
||||
"Loading…": "Memuatkan…",
|
||||
"No active tab": "Tiada tab aktif",
|
||||
"Offscreen page failed to start": "Halaman penukar gagal dimulakan",
|
||||
"Open web.readest.com": "Buka web.readest.com",
|
||||
"Page took too long to read": "Pembacaan halaman mengambil masa terlalu lama",
|
||||
"Reading page…": "Membaca halaman…",
|
||||
"Send to Readest": "Hantar ke Readest",
|
||||
"Sending to Readest…": "Menghantar ke Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Dihantar — {count} imej tidak dapat diambil.",
|
||||
"Sent — 1 image could not be fetched.": "Dihantar — 1 imej tidak dapat diambil.",
|
||||
"Sent — it will appear in your library shortly.": "Dihantar — akan muncul dalam perpustakaan anda tidak lama lagi.",
|
||||
"Server returned {status}": "Pelayan memulangkan {status}",
|
||||
"Session expired": "Sesi tamat tempoh",
|
||||
"Sign in at web.readest.com first": "Log masuk web.readest.com terlebih dahulu",
|
||||
"Sign in to Readest to start clipping pages.": "Log masuk Readest untuk mula menyimpan halaman.",
|
||||
"Signed out": "Dilog keluar",
|
||||
"Starting…": "Memulakan…",
|
||||
"This page cannot be clipped": "Halaman ini tidak boleh disimpan",
|
||||
"Unexpected server response": "Respons pelayan tidak dijangka",
|
||||
"Unknown error": "Ralat tidak diketahui"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artikel is te groot om te versturen",
|
||||
"Building EPUB…": "EPUB wordt opgebouwd…",
|
||||
"Capture script could not start on this page": "Het opnamescript kon niet starten op deze pagina",
|
||||
"Conversion timed out after {seconds}s": "Conversie verlopen na {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Opnamescript kan niet worden geïnjecteerd: {reason}",
|
||||
"Could not reach {host} — {reason}": "Kan {host} niet bereiken — {reason}",
|
||||
"Could not start the converter page: {reason}": "Kan de converter-pagina niet starten: {reason}",
|
||||
"Couldn't read this page": "Deze pagina kan niet worden gelezen",
|
||||
"Inbox is full": "Postvak is vol",
|
||||
"Loading…": "Laden…",
|
||||
"No active tab": "Geen actief tabblad",
|
||||
"Offscreen page failed to start": "Converter-pagina kon niet starten",
|
||||
"Open web.readest.com": "web.readest.com openen",
|
||||
"Page took too long to read": "Het lezen van de pagina duurde te lang",
|
||||
"Reading page…": "Pagina lezen…",
|
||||
"Send to Readest": "Naar Readest sturen",
|
||||
"Sending to Readest…": "Verzenden naar Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Verzonden — {count} afbeeldingen konden niet worden opgehaald.",
|
||||
"Sent — 1 image could not be fetched.": "Verzonden — 1 afbeelding kon niet worden opgehaald.",
|
||||
"Sent — it will appear in your library shortly.": "Verzonden — verschijnt binnenkort in je bibliotheek.",
|
||||
"Server returned {status}": "Server gaf {status} terug",
|
||||
"Session expired": "Sessie verlopen",
|
||||
"Sign in at web.readest.com first": "Meld je eerst aan op web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Meld je aan bij Readest om pagina’s op te slaan.",
|
||||
"Signed out": "Afgemeld",
|
||||
"Starting…": "Starten…",
|
||||
"This page cannot be clipped": "Deze pagina kan niet worden opgeslagen",
|
||||
"Unexpected server response": "Onverwacht antwoord van server",
|
||||
"Unknown error": "Onbekende fout"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artykuł jest zbyt duży, aby go wysłać",
|
||||
"Building EPUB…": "Tworzenie EPUB…",
|
||||
"Capture script could not start on this page": "Skrypt przechwytujący nie mógł uruchomić się na tej stronie",
|
||||
"Conversion timed out after {seconds}s": "Konwersja zakończyła się po {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Nie udało się wstrzyknąć skryptu przechwytującego: {reason}",
|
||||
"Could not reach {host} — {reason}": "Nie można połączyć się z {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Nie udało się uruchomić strony konwertera: {reason}",
|
||||
"Couldn't read this page": "Nie udało się odczytać tej strony",
|
||||
"Inbox is full": "Skrzynka odbiorcza jest pełna",
|
||||
"Loading…": "Wczytywanie…",
|
||||
"No active tab": "Brak aktywnej karty",
|
||||
"Offscreen page failed to start": "Strona konwertera nie mogła się uruchomić",
|
||||
"Open web.readest.com": "Otwórz web.readest.com",
|
||||
"Page took too long to read": "Wczytanie strony trwało zbyt długo",
|
||||
"Reading page…": "Wczytywanie strony…",
|
||||
"Send to Readest": "Wyślij do Readest",
|
||||
"Sending to Readest…": "Wysyłanie do Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Wysłano — nie udało się pobrać {count} obrazów.",
|
||||
"Sent — 1 image could not be fetched.": "Wysłano — nie udało się pobrać 1 obrazu.",
|
||||
"Sent — it will appear in your library shortly.": "Wysłano — wkrótce pojawi się w Twojej bibliotece.",
|
||||
"Server returned {status}": "Serwer zwrócił {status}",
|
||||
"Session expired": "Sesja wygasła",
|
||||
"Sign in at web.readest.com first": "Najpierw zaloguj się na web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Zaloguj się do Readest, aby zacząć zapisywać strony.",
|
||||
"Signed out": "Wylogowano",
|
||||
"Starting…": "Uruchamianie…",
|
||||
"This page cannot be clipped": "Tej strony nie można zapisać",
|
||||
"Unexpected server response": "Nieoczekiwana odpowiedź serwera",
|
||||
"Unknown error": "Nieznany błąd"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "O artigo é grande demais para ser enviado",
|
||||
"Building EPUB…": "Criando EPUB…",
|
||||
"Capture script could not start on this page": "O script de captura não pôde iniciar nesta página",
|
||||
"Conversion timed out after {seconds}s": "Conversão expirou após {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Não foi possível injetar o script de captura: {reason}",
|
||||
"Could not reach {host} — {reason}": "Não foi possível alcançar {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Não foi possível iniciar a página do conversor: {reason}",
|
||||
"Couldn't read this page": "Não foi possível ler esta página",
|
||||
"Inbox is full": "A caixa de entrada está cheia",
|
||||
"Loading…": "Carregando…",
|
||||
"No active tab": "Nenhuma aba ativa",
|
||||
"Offscreen page failed to start": "A página do conversor não iniciou",
|
||||
"Open web.readest.com": "Abrir web.readest.com",
|
||||
"Page took too long to read": "A leitura da página demorou demais",
|
||||
"Reading page…": "Lendo a página…",
|
||||
"Send to Readest": "Enviar para o Readest",
|
||||
"Sending to Readest…": "Enviando para o Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Enviado — não foi possível obter {count} imagens.",
|
||||
"Sent — 1 image could not be fetched.": "Enviado — não foi possível obter 1 imagem.",
|
||||
"Sent — it will appear in your library shortly.": "Enviado — aparecerá em sua biblioteca em breve.",
|
||||
"Server returned {status}": "O servidor retornou {status}",
|
||||
"Session expired": "Sessão expirada",
|
||||
"Sign in at web.readest.com first": "Faça login primeiro em web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Faça login no Readest para começar a salvar páginas.",
|
||||
"Signed out": "Desconectado",
|
||||
"Starting…": "Iniciando…",
|
||||
"This page cannot be clipped": "Esta página não pode ser salva",
|
||||
"Unexpected server response": "Resposta inesperada do servidor",
|
||||
"Unknown error": "Erro desconhecido"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "O artigo é demasiado grande para enviar",
|
||||
"Building EPUB…": "A criar EPUB…",
|
||||
"Capture script could not start on this page": "O script de captura não conseguiu iniciar nesta página",
|
||||
"Conversion timed out after {seconds}s": "Conversão expirou após {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Não foi possível injetar o script de captura: {reason}",
|
||||
"Could not reach {host} — {reason}": "Não foi possível alcançar {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Não foi possível iniciar a página do conversor: {reason}",
|
||||
"Couldn't read this page": "Não foi possível ler esta página",
|
||||
"Inbox is full": "A caixa de entrada está cheia",
|
||||
"Loading…": "A carregar…",
|
||||
"No active tab": "Nenhum separador ativo",
|
||||
"Offscreen page failed to start": "A página do conversor não iniciou",
|
||||
"Open web.readest.com": "Abrir web.readest.com",
|
||||
"Page took too long to read": "A leitura da página demorou demasiado",
|
||||
"Reading page…": "A ler a página…",
|
||||
"Send to Readest": "Enviar para Readest",
|
||||
"Sending to Readest…": "A enviar para Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Enviado — não foi possível obter {count} imagens.",
|
||||
"Sent — 1 image could not be fetched.": "Enviado — não foi possível obter 1 imagem.",
|
||||
"Sent — it will appear in your library shortly.": "Enviado — aparecerá em breve na sua biblioteca.",
|
||||
"Server returned {status}": "Servidor devolveu {status}",
|
||||
"Session expired": "Sessão expirada",
|
||||
"Sign in at web.readest.com first": "Inicie sessão primeiro em web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Inicie sessão em Readest para começar a guardar páginas.",
|
||||
"Signed out": "Sessão terminada",
|
||||
"Starting…": "A iniciar…",
|
||||
"This page cannot be clipped": "Esta página não pode ser guardada",
|
||||
"Unexpected server response": "Resposta inesperada do servidor",
|
||||
"Unknown error": "Erro desconhecido"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Articolul este prea mare pentru a fi trimis",
|
||||
"Building EPUB…": "Se construiește EPUB…",
|
||||
"Capture script could not start on this page": "Scriptul de captură nu a putut porni pe această pagină",
|
||||
"Conversion timed out after {seconds}s": "Conversia a expirat după {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Nu s-a putut injecta scriptul de captură: {reason}",
|
||||
"Could not reach {host} — {reason}": "Nu se poate accesa {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Nu s-a putut porni pagina convertorului: {reason}",
|
||||
"Couldn't read this page": "Această pagină nu a putut fi citită",
|
||||
"Inbox is full": "Inbox-ul este plin",
|
||||
"Loading…": "Se încarcă…",
|
||||
"No active tab": "Nicio filă activă",
|
||||
"Offscreen page failed to start": "Pagina convertorului nu a putut porni",
|
||||
"Open web.readest.com": "Deschide web.readest.com",
|
||||
"Page took too long to read": "Citirea paginii a durat prea mult",
|
||||
"Reading page…": "Se citește pagina…",
|
||||
"Send to Readest": "Trimite la Readest",
|
||||
"Sending to Readest…": "Se trimite la Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Trimis — nu s-au putut prelua {count} imagini.",
|
||||
"Sent — 1 image could not be fetched.": "Trimis — nu s-a putut prelua 1 imagine.",
|
||||
"Sent — it will appear in your library shortly.": "Trimis — va apărea în curând în biblioteca dvs.",
|
||||
"Server returned {status}": "Serverul a returnat {status}",
|
||||
"Session expired": "Sesiunea a expirat",
|
||||
"Sign in at web.readest.com first": "Conectați-vă mai întâi la web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Conectați-vă la Readest pentru a începe să salvați pagini.",
|
||||
"Signed out": "Deconectat",
|
||||
"Starting…": "Se pornește…",
|
||||
"This page cannot be clipped": "Această pagină nu poate fi salvată",
|
||||
"Unexpected server response": "Răspuns neașteptat de la server",
|
||||
"Unknown error": "Eroare necunoscută"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Статья слишком велика для отправки",
|
||||
"Building EPUB…": "Создание EPUB…",
|
||||
"Capture script could not start on this page": "Сценарий захвата не смог запуститься на этой странице",
|
||||
"Conversion timed out after {seconds}s": "Время преобразования истекло через {seconds} с",
|
||||
"Could not inject capture script: {reason}": "Не удалось внедрить сценарий захвата: {reason}",
|
||||
"Could not reach {host} — {reason}": "Не удалось соединиться с {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Не удалось запустить страницу преобразователя: {reason}",
|
||||
"Couldn't read this page": "Не удалось прочитать эту страницу",
|
||||
"Inbox is full": "Папка «Входящие» переполнена",
|
||||
"Loading…": "Загрузка…",
|
||||
"No active tab": "Нет активной вкладки",
|
||||
"Offscreen page failed to start": "Страница преобразователя не запустилась",
|
||||
"Open web.readest.com": "Открыть web.readest.com",
|
||||
"Page took too long to read": "Чтение страницы заняло слишком много времени",
|
||||
"Reading page…": "Чтение страницы…",
|
||||
"Send to Readest": "Отправить в Readest",
|
||||
"Sending to Readest…": "Отправка в Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Отправлено — не удалось получить {count} изображений.",
|
||||
"Sent — 1 image could not be fetched.": "Отправлено — не удалось получить 1 изображение.",
|
||||
"Sent — it will appear in your library shortly.": "Отправлено — вскоре появится в вашей библиотеке.",
|
||||
"Server returned {status}": "Сервер вернул {status}",
|
||||
"Session expired": "Сеанс истёк",
|
||||
"Sign in at web.readest.com first": "Сначала войдите на web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Войдите в Readest, чтобы начать сохранять страницы.",
|
||||
"Signed out": "Выполнен выход",
|
||||
"Starting…": "Запуск…",
|
||||
"This page cannot be clipped": "Эту страницу невозможно сохранить",
|
||||
"Unexpected server response": "Непредвиденный ответ сервера",
|
||||
"Unknown error": "Неизвестная ошибка"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "ලිපිය යැවීමට ඉතා විශාලයි",
|
||||
"Building EPUB…": "EPUB ගොඩනඟමින්…",
|
||||
"Capture script could not start on this page": "මෙම පිටුවේ ග්රහණ ස්ක්රිප්ටය ආරම්භ කළ නොහැකි විය",
|
||||
"Conversion timed out after {seconds}s": "තත්පර {seconds}කට පසු පරිවර්තනය කල් ඉකුත් විය",
|
||||
"Could not inject capture script: {reason}": "ග්රහණ ස්ක්රිප්ටය ඇතුළත් කළ නොහැකි විය: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} වෙත ළඟා විය නොහැක — {reason}",
|
||||
"Could not start the converter page: {reason}": "පරිවර්තක පිටුව ආරම්භ කළ නොහැක: {reason}",
|
||||
"Couldn't read this page": "මෙම පිටුව කියවිය නොහැකි විය",
|
||||
"Inbox is full": "ලැබුණු පෙට්ටිය පිරී ඇත",
|
||||
"Loading…": "පූරණය වෙමින්…",
|
||||
"No active tab": "සක්රීය පටිත්තක් නැත",
|
||||
"Offscreen page failed to start": "පරිවර්තක පිටුව ආරම්භ වීමට අසමත් විය",
|
||||
"Open web.readest.com": "web.readest.com විවෘත කරන්න",
|
||||
"Page took too long to read": "පිටුව කියවීමට වැඩි කාලයක් ගත විය",
|
||||
"Reading page…": "පිටුව කියවමින්…",
|
||||
"Send to Readest": "Readest වෙත යවන්න",
|
||||
"Sending to Readest…": "Readest වෙත යවමින්…",
|
||||
"Sent — {count} images could not be fetched.": "යවන ලදී — රූප {count}ක් ලබා ගත නොහැකි විය.",
|
||||
"Sent — 1 image could not be fetched.": "යවන ලදී — රූප 1ක් ලබා ගත නොහැකි විය.",
|
||||
"Sent — it will appear in your library shortly.": "යවන ලදී — ඉක්මනින් ඔබේ පුස්තකාලයේ දිස්වනු ඇත.",
|
||||
"Server returned {status}": "සේවාදායකය {status} ලබා දුන්නේය",
|
||||
"Session expired": "සැසිය කල් ඉකුත් වී ඇත",
|
||||
"Sign in at web.readest.com first": "පළමුව web.readest.com වෙත පුරනය වන්න",
|
||||
"Sign in to Readest to start clipping pages.": "පිටු සුරැකීම ආරම්භ කිරීමට Readest වෙත පුරනය වන්න.",
|
||||
"Signed out": "පුරනය වී නැත",
|
||||
"Starting…": "ආරම්භ වෙමින්…",
|
||||
"This page cannot be clipped": "මෙම පිටුව සුරැකිය නොහැක",
|
||||
"Unexpected server response": "අනපේක්ෂිත සේවාදායක ප්රතිචාරය",
|
||||
"Unknown error": "නොදන්නා දෝෂය"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Članek je prevelik za pošiljanje",
|
||||
"Building EPUB…": "Ustvarjanje EPUB…",
|
||||
"Capture script could not start on this page": "Skripta za zajem ni mogla začeti na tej strani",
|
||||
"Conversion timed out after {seconds}s": "Pretvorba je potekla po {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Skripte za zajem ni bilo mogoče vbrizgati: {reason}",
|
||||
"Could not reach {host} — {reason}": "Ni mogoče doseči {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Strani pretvornika ni bilo mogoče zagnati: {reason}",
|
||||
"Couldn't read this page": "Te strani ni bilo mogoče prebrati",
|
||||
"Inbox is full": "Mapa Prejeto je polna",
|
||||
"Loading…": "Nalaganje…",
|
||||
"No active tab": "Ni aktivnega zavihka",
|
||||
"Offscreen page failed to start": "Strani pretvornika ni bilo mogoče zagnati",
|
||||
"Open web.readest.com": "Odpri web.readest.com",
|
||||
"Page took too long to read": "Branje strani je trajalo predolgo",
|
||||
"Reading page…": "Branje strani…",
|
||||
"Send to Readest": "Pošlji v Readest",
|
||||
"Sending to Readest…": "Pošiljanje v Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Poslano — {count} slik ni bilo mogoče pridobiti.",
|
||||
"Sent — 1 image could not be fetched.": "Poslano — 1 slike ni bilo mogoče pridobiti.",
|
||||
"Sent — it will appear in your library shortly.": "Poslano — kmalu se bo prikazalo v vaši knjižnici.",
|
||||
"Server returned {status}": "Strežnik je vrnil {status}",
|
||||
"Session expired": "Seja je potekla",
|
||||
"Sign in at web.readest.com first": "Najprej se prijavite na web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Prijavite se v Readest, da začnete shranjevati strani.",
|
||||
"Signed out": "Odjavljeno",
|
||||
"Starting…": "Zagon…",
|
||||
"This page cannot be clipped": "Te strani ni mogoče shraniti",
|
||||
"Unexpected server response": "Nepričakovan odziv strežnika",
|
||||
"Unknown error": "Neznana napaka"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Artikeln är för stor för att skickas",
|
||||
"Building EPUB…": "Skapar EPUB…",
|
||||
"Capture script could not start on this page": "Insamlingsskriptet kunde inte starta på den här sidan",
|
||||
"Conversion timed out after {seconds}s": "Konverteringen tog slut efter {seconds} s",
|
||||
"Could not inject capture script: {reason}": "Det gick inte att infoga insamlingsskriptet: {reason}",
|
||||
"Could not reach {host} — {reason}": "Det gick inte att nå {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Det gick inte att starta konverteringssidan: {reason}",
|
||||
"Couldn't read this page": "Det gick inte att läsa den här sidan",
|
||||
"Inbox is full": "Inkorgen är full",
|
||||
"Loading…": "Läser in…",
|
||||
"No active tab": "Ingen aktiv flik",
|
||||
"Offscreen page failed to start": "Konverteringssidan kunde inte starta",
|
||||
"Open web.readest.com": "Öppna web.readest.com",
|
||||
"Page took too long to read": "Sidan tog för lång tid att läsa",
|
||||
"Reading page…": "Läser sidan…",
|
||||
"Send to Readest": "Skicka till Readest",
|
||||
"Sending to Readest…": "Skickar till Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Skickad — {count} bilder kunde inte hämtas.",
|
||||
"Sent — 1 image could not be fetched.": "Skickad — 1 bild kunde inte hämtas.",
|
||||
"Sent — it will appear in your library shortly.": "Skickad — kommer att visas i ditt bibliotek inom kort.",
|
||||
"Server returned {status}": "Servern svarade {status}",
|
||||
"Session expired": "Sessionen har gått ut",
|
||||
"Sign in at web.readest.com first": "Logga in på web.readest.com först",
|
||||
"Sign in to Readest to start clipping pages.": "Logga in på Readest för att börja spara sidor.",
|
||||
"Signed out": "Utloggad",
|
||||
"Starting…": "Startar…",
|
||||
"This page cannot be clipped": "Den här sidan kan inte sparas",
|
||||
"Unexpected server response": "Oväntat svar från servern",
|
||||
"Unknown error": "Okänt fel"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "கட்டுரை அனுப்ப மிக பெரியது",
|
||||
"Building EPUB…": "EPUB உருவாக்கப்படுகிறது…",
|
||||
"Capture script could not start on this page": "இந்த பக்கத்தில் கைப்பற்றும் ஸ்கிரிப்ட் தொடங்க முடியவில்லை",
|
||||
"Conversion timed out after {seconds}s": "மாற்றம் {seconds} வினாடிகளுக்குப் பிறகு காலாவதியானது",
|
||||
"Could not inject capture script: {reason}": "கைப்பற்றும் ஸ்கிரிப்ட்டை செலுத்த முடியவில்லை: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host}-ஐ அடைய முடியவில்லை — {reason}",
|
||||
"Could not start the converter page: {reason}": "மாற்றியின் பக்கத்தைத் தொடங்க முடியவில்லை: {reason}",
|
||||
"Couldn't read this page": "இந்த பக்கத்தை வாசிக்க முடியவில்லை",
|
||||
"Inbox is full": "உள்பெட்டி நிரம்பியுள்ளது",
|
||||
"Loading…": "ஏற்றப்படுகிறது…",
|
||||
"No active tab": "செயலில் உள்ள தாவல் இல்லை",
|
||||
"Offscreen page failed to start": "மாற்றியின் பக்கம் தொடங்க முடியவில்லை",
|
||||
"Open web.readest.com": "web.readest.com திற",
|
||||
"Page took too long to read": "பக்கம் வாசிக்க அதிக நேரம் ஆனது",
|
||||
"Reading page…": "பக்கம் வாசிக்கப்படுகிறது…",
|
||||
"Send to Readest": "Readest-க்கு அனுப்பு",
|
||||
"Sending to Readest…": "Readest-க்கு அனுப்பப்படுகிறது…",
|
||||
"Sent — {count} images could not be fetched.": "அனுப்பப்பட்டது — {count} படங்களைப் பெற முடியவில்லை.",
|
||||
"Sent — 1 image could not be fetched.": "அனுப்பப்பட்டது — 1 படத்தைப் பெற முடியவில்லை.",
|
||||
"Sent — it will appear in your library shortly.": "அனுப்பப்பட்டது — விரைவில் உங்கள் நூலகத்தில் தோன்றும்.",
|
||||
"Server returned {status}": "சேவையகம் {status}-ஐ வழங்கியது",
|
||||
"Session expired": "அமர்வு காலாவதியானது",
|
||||
"Sign in at web.readest.com first": "முதலில் web.readest.com-இல் உள்நுழையவும்",
|
||||
"Sign in to Readest to start clipping pages.": "பக்கங்களைச் சேமிக்க தொடங்க Readest-இல் உள்நுழையவும்.",
|
||||
"Signed out": "வெளியேறியது",
|
||||
"Starting…": "தொடங்குகிறது…",
|
||||
"This page cannot be clipped": "இந்த பக்கத்தை சேமிக்க முடியாது",
|
||||
"Unexpected server response": "எதிர்பாராத சேவையக பதில்",
|
||||
"Unknown error": "அறியப்படாத பிழை"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "บทความมีขนาดใหญ่เกินกว่าจะส่ง",
|
||||
"Building EPUB…": "กำลังสร้าง EPUB…",
|
||||
"Capture script could not start on this page": "สคริปต์การจับไม่สามารถเริ่มทำงานบนหน้านี้",
|
||||
"Conversion timed out after {seconds}s": "การแปลงหมดเวลาหลังจาก {seconds} วินาที",
|
||||
"Could not inject capture script: {reason}": "ไม่สามารถแทรกสคริปต์การจับได้: {reason}",
|
||||
"Could not reach {host} — {reason}": "ไม่สามารถเข้าถึง {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "ไม่สามารถเริ่มหน้าตัวแปลงได้: {reason}",
|
||||
"Couldn't read this page": "ไม่สามารถอ่านหน้านี้ได้",
|
||||
"Inbox is full": "กล่องขาเข้าเต็ม",
|
||||
"Loading…": "กำลังโหลด…",
|
||||
"No active tab": "ไม่มีแท็บที่ใช้งานอยู่",
|
||||
"Offscreen page failed to start": "หน้าตัวแปลงเริ่มทำงานไม่สำเร็จ",
|
||||
"Open web.readest.com": "เปิด web.readest.com",
|
||||
"Page took too long to read": "การอ่านหน้าใช้เวลานานเกินไป",
|
||||
"Reading page…": "กำลังอ่านหน้า…",
|
||||
"Send to Readest": "ส่งไปยัง Readest",
|
||||
"Sending to Readest…": "กำลังส่งไปยัง Readest…",
|
||||
"Sent — {count} images could not be fetched.": "ส่งแล้ว — ดึงภาพ {count} ภาพไม่ได้",
|
||||
"Sent — 1 image could not be fetched.": "ส่งแล้ว — ดึงภาพ 1 ภาพไม่ได้",
|
||||
"Sent — it will appear in your library shortly.": "ส่งแล้ว — จะปรากฏในคลังของคุณในไม่ช้า",
|
||||
"Server returned {status}": "เซิร์ฟเวอร์ส่งคืน {status}",
|
||||
"Session expired": "เซสชันหมดอายุ",
|
||||
"Sign in at web.readest.com first": "ลงชื่อเข้าใช้ web.readest.com ก่อน",
|
||||
"Sign in to Readest to start clipping pages.": "ลงชื่อเข้าใช้ Readest เพื่อเริ่มบันทึกหน้า",
|
||||
"Signed out": "ออกจากระบบแล้ว",
|
||||
"Starting…": "กำลังเริ่ม…",
|
||||
"This page cannot be clipped": "ไม่สามารถบันทึกหน้านี้ได้",
|
||||
"Unexpected server response": "การตอบกลับของเซิร์ฟเวอร์ที่ไม่คาดคิด",
|
||||
"Unknown error": "ข้อผิดพลาดที่ไม่รู้จัก"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Makale göndermek için çok büyük",
|
||||
"Building EPUB…": "EPUB oluşturuluyor…",
|
||||
"Capture script could not start on this page": "Yakalama betiği bu sayfada başlayamadı",
|
||||
"Conversion timed out after {seconds}s": "Dönüştürme {seconds} sn sonra zaman aşımına uğradı",
|
||||
"Could not inject capture script: {reason}": "Yakalama betiği enjekte edilemedi: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} adresine ulaşılamadı — {reason}",
|
||||
"Could not start the converter page: {reason}": "Dönüştürücü sayfası başlatılamadı: {reason}",
|
||||
"Couldn't read this page": "Bu sayfa okunamadı",
|
||||
"Inbox is full": "Gelen kutusu dolu",
|
||||
"Loading…": "Yükleniyor…",
|
||||
"No active tab": "Etkin sekme yok",
|
||||
"Offscreen page failed to start": "Dönüştürücü sayfası başlatılamadı",
|
||||
"Open web.readest.com": "web.readest.com sayfasını aç",
|
||||
"Page took too long to read": "Sayfanın okunması çok uzun sürdü",
|
||||
"Reading page…": "Sayfa okunuyor…",
|
||||
"Send to Readest": "Readest'e gönder",
|
||||
"Sending to Readest…": "Readest'e gönderiliyor…",
|
||||
"Sent — {count} images could not be fetched.": "Gönderildi — {count} görsel alınamadı.",
|
||||
"Sent — 1 image could not be fetched.": "Gönderildi — 1 görsel alınamadı.",
|
||||
"Sent — it will appear in your library shortly.": "Gönderildi — kısa süre içinde kitaplığınızda görünecek.",
|
||||
"Server returned {status}": "Sunucu {status} döndürdü",
|
||||
"Session expired": "Oturum süresi doldu",
|
||||
"Sign in at web.readest.com first": "Önce web.readest.com adresine giriş yapın",
|
||||
"Sign in to Readest to start clipping pages.": "Sayfaları kaydetmeye başlamak için Readest'e giriş yapın.",
|
||||
"Signed out": "Oturum kapatıldı",
|
||||
"Starting…": "Başlatılıyor…",
|
||||
"This page cannot be clipped": "Bu sayfa kaydedilemez",
|
||||
"Unexpected server response": "Beklenmedik sunucu yanıtı",
|
||||
"Unknown error": "Bilinmeyen hata"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Стаття занадто велика для надсилання",
|
||||
"Building EPUB…": "Створення EPUB…",
|
||||
"Capture script could not start on this page": "Скрипт захоплення не зміг запуститися на цій сторінці",
|
||||
"Conversion timed out after {seconds}s": "Перетворення завершилося за {seconds} с",
|
||||
"Could not inject capture script: {reason}": "Не вдалося вставити скрипт захоплення: {reason}",
|
||||
"Could not reach {host} — {reason}": "Не вдалося з’єднатися з {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Не вдалося запустити сторінку конвертера: {reason}",
|
||||
"Couldn't read this page": "Не вдалося прочитати цю сторінку",
|
||||
"Inbox is full": "Поштова скринька переповнена",
|
||||
"Loading…": "Завантаження…",
|
||||
"No active tab": "Немає активної вкладки",
|
||||
"Offscreen page failed to start": "Не вдалося запустити сторінку конвертера",
|
||||
"Open web.readest.com": "Відкрити web.readest.com",
|
||||
"Page took too long to read": "Сторінка читалася занадто довго",
|
||||
"Reading page…": "Читання сторінки…",
|
||||
"Send to Readest": "Надіслати до Readest",
|
||||
"Sending to Readest…": "Надсилання до Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Надіслано — не вдалося отримати {count} зображень.",
|
||||
"Sent — 1 image could not be fetched.": "Надіслано — не вдалося отримати 1 зображення.",
|
||||
"Sent — it will appear in your library shortly.": "Надіслано — невдовзі з’явиться у вашій бібліотеці.",
|
||||
"Server returned {status}": "Сервер повернув {status}",
|
||||
"Session expired": "Сеанс завершено",
|
||||
"Sign in at web.readest.com first": "Спочатку увійдіть на web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "Увійдіть до Readest, щоб почати зберігати сторінки.",
|
||||
"Signed out": "Не виконано вхід",
|
||||
"Starting…": "Запуск…",
|
||||
"This page cannot be clipped": "Цю сторінку неможливо зберегти",
|
||||
"Unexpected server response": "Несподівана відповідь сервера",
|
||||
"Unknown error": "Невідома помилка"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Maqola yuborish uchun juda katta",
|
||||
"Building EPUB…": "EPUB yaratilmoqda…",
|
||||
"Capture script could not start on this page": "Saqlash skripti ushbu sahifada ishga tushmadi",
|
||||
"Conversion timed out after {seconds}s": "Aylantirish {seconds} soniyadan keyin tugadi",
|
||||
"Could not inject capture script: {reason}": "Saqlash skriptini joriy etib bo'lmadi: {reason}",
|
||||
"Could not reach {host} — {reason}": "{host} bilan bog'lanib bo'lmadi — {reason}",
|
||||
"Could not start the converter page: {reason}": "Aylantirgich sahifasini boshlab bo'lmadi: {reason}",
|
||||
"Couldn't read this page": "Ushbu sahifani o'qib bo'lmadi",
|
||||
"Inbox is full": "Kiruvchi qutisi to'la",
|
||||
"Loading…": "Yuklanmoqda…",
|
||||
"No active tab": "Faol yorliq yo‘q",
|
||||
"Offscreen page failed to start": "Aylantirgich sahifasi ishga tushmadi",
|
||||
"Open web.readest.com": "web.readest.com sahifasini ochish",
|
||||
"Page took too long to read": "Sahifani o‘qish juda uzoq vaqt oldi",
|
||||
"Reading page…": "Sahifa o‘qilmoqda…",
|
||||
"Send to Readest": "Readest'ga yuborish",
|
||||
"Sending to Readest…": "Readest'ga yuborilmoqda…",
|
||||
"Sent — {count} images could not be fetched.": "Yuborildi — {count} ta rasmni olib bo‘lmadi.",
|
||||
"Sent — 1 image could not be fetched.": "Yuborildi — 1 ta rasmni olib bo‘lmadi.",
|
||||
"Sent — it will appear in your library shortly.": "Yuborildi — tez orada kutubxonangizda paydo bo‘ladi.",
|
||||
"Server returned {status}": "Server {status} qaytardi",
|
||||
"Session expired": "Seans muddati tugadi",
|
||||
"Sign in at web.readest.com first": "Avval web.readest.com saytiga kiring",
|
||||
"Sign in to Readest to start clipping pages.": "Sahifalarni saqlashni boshlash uchun Readest'ga kiring.",
|
||||
"Signed out": "Tizimdan chiqildi",
|
||||
"Starting…": "Boshlanyapti…",
|
||||
"This page cannot be clipped": "Bu sahifani saqlab bo‘lmaydi",
|
||||
"Unexpected server response": "Kutilmagan server javobi",
|
||||
"Unknown error": "Noma'lum xato"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "Bài viết quá lớn để gửi",
|
||||
"Building EPUB…": "Đang tạo EPUB…",
|
||||
"Capture script could not start on this page": "Tập lệnh chụp không thể khởi động trên trang này",
|
||||
"Conversion timed out after {seconds}s": "Chuyển đổi đã hết thời gian sau {seconds} giây",
|
||||
"Could not inject capture script: {reason}": "Không thể chèn tập lệnh chụp: {reason}",
|
||||
"Could not reach {host} — {reason}": "Không thể kết nối tới {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "Không thể khởi động trang chuyển đổi: {reason}",
|
||||
"Couldn't read this page": "Không thể đọc trang này",
|
||||
"Inbox is full": "Hộp thư đến đã đầy",
|
||||
"Loading…": "Đang tải…",
|
||||
"No active tab": "Không có thẻ đang hoạt động",
|
||||
"Offscreen page failed to start": "Trang chuyển đổi không khởi động được",
|
||||
"Open web.readest.com": "Mở web.readest.com",
|
||||
"Page took too long to read": "Trang đọc mất quá nhiều thời gian",
|
||||
"Reading page…": "Đang đọc trang…",
|
||||
"Send to Readest": "Gửi đến Readest",
|
||||
"Sending to Readest…": "Đang gửi đến Readest…",
|
||||
"Sent — {count} images could not be fetched.": "Đã gửi — không thể tải {count} hình ảnh.",
|
||||
"Sent — 1 image could not be fetched.": "Đã gửi — không thể tải 1 hình ảnh.",
|
||||
"Sent — it will appear in your library shortly.": "Đã gửi — sẽ sớm xuất hiện trong thư viện của bạn.",
|
||||
"Server returned {status}": "Máy chủ trả về {status}",
|
||||
"Session expired": "Phiên đã hết hạn",
|
||||
"Sign in at web.readest.com first": "Hãy đăng nhập web.readest.com trước",
|
||||
"Sign in to Readest to start clipping pages.": "Đăng nhập Readest để bắt đầu lưu trang.",
|
||||
"Signed out": "Đã đăng xuất",
|
||||
"Starting…": "Đang bắt đầu…",
|
||||
"This page cannot be clipped": "Trang này không thể lưu",
|
||||
"Unexpected server response": "Phản hồi máy chủ không mong đợi",
|
||||
"Unknown error": "Lỗi không xác định"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "文章过大,无法发送",
|
||||
"Building EPUB…": "正在构建 EPUB…",
|
||||
"Capture script could not start on this page": "抓取脚本无法在此页面启动",
|
||||
"Conversion timed out after {seconds}s": "{seconds} 秒后转换超时",
|
||||
"Could not inject capture script: {reason}": "无法注入抓取脚本:{reason}",
|
||||
"Could not reach {host} — {reason}": "无法连接到 {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "无法启动转换器页面:{reason}",
|
||||
"Couldn't read this page": "无法读取此页面",
|
||||
"Inbox is full": "收件箱已满",
|
||||
"Loading…": "正在加载…",
|
||||
"No active tab": "没有活动标签页",
|
||||
"Offscreen page failed to start": "转换器页面启动失败",
|
||||
"Open web.readest.com": "打开 web.readest.com",
|
||||
"Page took too long to read": "页面读取耗时过长",
|
||||
"Reading page…": "正在读取页面…",
|
||||
"Send to Readest": "发送到 Readest",
|
||||
"Sending to Readest…": "正在发送到 Readest…",
|
||||
"Sent — {count} images could not be fetched.": "已发送 — {count} 张图片无法获取。",
|
||||
"Sent — 1 image could not be fetched.": "已发送 — 1 张图片无法获取。",
|
||||
"Sent — it will appear in your library shortly.": "已发送 — 稍后将出现在你的书库中。",
|
||||
"Server returned {status}": "服务器返回 {status}",
|
||||
"Session expired": "会话已过期",
|
||||
"Sign in at web.readest.com first": "请先登录 web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "登录 Readest 以开始保存页面。",
|
||||
"Signed out": "已退出",
|
||||
"Starting…": "正在启动…",
|
||||
"This page cannot be clipped": "此页面无法保存",
|
||||
"Unexpected server response": "意外的服务器响应",
|
||||
"Unknown error": "未知错误"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"Article is too large to send": "文章過大,無法傳送",
|
||||
"Building EPUB…": "正在建立 EPUB…",
|
||||
"Capture script could not start on this page": "擷取指令碼無法在此頁面啟動",
|
||||
"Conversion timed out after {seconds}s": "{seconds} 秒後轉換逾時",
|
||||
"Could not inject capture script: {reason}": "無法插入擷取指令碼:{reason}",
|
||||
"Could not reach {host} — {reason}": "無法連線至 {host} — {reason}",
|
||||
"Could not start the converter page: {reason}": "無法啟動轉換器頁面:{reason}",
|
||||
"Couldn't read this page": "無法讀取此頁面",
|
||||
"Inbox is full": "收件匣已滿",
|
||||
"Loading…": "載入中…",
|
||||
"No active tab": "沒有作用中分頁",
|
||||
"Offscreen page failed to start": "轉換器頁面啟動失敗",
|
||||
"Open web.readest.com": "開啟 web.readest.com",
|
||||
"Page took too long to read": "頁面讀取耗時過長",
|
||||
"Reading page…": "正在讀取頁面…",
|
||||
"Send to Readest": "傳送至 Readest",
|
||||
"Sending to Readest…": "正在傳送至 Readest…",
|
||||
"Sent — {count} images could not be fetched.": "已傳送 — {count} 張圖片無法擷取。",
|
||||
"Sent — 1 image could not be fetched.": "已傳送 — 1 張圖片無法擷取。",
|
||||
"Sent — it will appear in your library shortly.": "已傳送 — 稍後將出現在您的書庫中。",
|
||||
"Server returned {status}": "伺服器回傳 {status}",
|
||||
"Session expired": "工作階段已過期",
|
||||
"Sign in at web.readest.com first": "請先登入 web.readest.com",
|
||||
"Sign in to Readest to start clipping pages.": "登入 Readest 以開始儲存頁面。",
|
||||
"Signed out": "已登出",
|
||||
"Starting…": "正在啟動…",
|
||||
"This page cannot be clipped": "此頁面無法儲存",
|
||||
"Unexpected server response": "非預期的伺服器回應",
|
||||
"Unknown error": "未知的錯誤"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user