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:
Huang Xin
2026-05-22 20:45:29 +08:00
committed by GitHub
parent 9ad43aa8b2
commit f6dfd09d82
115 changed files with 5187 additions and 215 deletions
@@ -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 webpaginas 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 lEPUB…",
"Capture script could not start on this page": "Le script de capture na 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 dinjecter le script de capture : {reason}",
"Could not reach {host} — {reason}": "Impossible datteindre {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 nont pas pu être récupérées.",
"Sent — 1 image could not be fetched.": "Envoyé — 1 image na pas pu être récupérée.",
"Sent — it will appear in your library shortly.": "Envoyé — larticle 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 dabord 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 dellEPUB…",
"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 paginas 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 yoq",
"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 oqish juda uzoq vaqt oldi",
"Reading page…": "Sahifa oqilmoqda…",
"Send to Readest": "Readest'ga yuborish",
"Sending to Readest…": "Readest'ga yuborilmoqda…",
"Sent — {count} images could not be fetched.": "Yuborildi — {count} ta rasmni olib bolmadi.",
"Sent — 1 image could not be fetched.": "Yuborildi — 1 ta rasmni olib bolmadi.",
"Sent — it will appear in your library shortly.": "Yuborildi — tez orada kutubxonangizda paydo boladi.",
"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 bolmaydi",
"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