diff --git a/apps/readest-app/extension/send-to-readest/README.md b/apps/readest-app/extension/send-to-readest/README.md deleted file mode 100644 index baad6353..00000000 --- a/apps/readest-app/extension/send-to-readest/README.md +++ /dev/null @@ -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 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). diff --git a/apps/readest-app/extension/send-to-readest/content.js b/apps/readest-app/extension/send-to-readest/content.js deleted file mode 100644 index 8d18c3d2..00000000 --- a/apps/readest-app/extension/send-to-readest/content.js +++ /dev/null @@ -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() }); - } -})(); diff --git a/apps/readest-app/extension/send-to-readest/manifest.json b/apps/readest-app/extension/send-to-readest/manifest.json deleted file mode 100644 index 4a908693..00000000 --- a/apps/readest-app/extension/send-to-readest/manifest.json +++ /dev/null @@ -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" - } - ] -} diff --git a/apps/readest-app/extension/send-to-readest/popup.html b/apps/readest-app/extension/send-to-readest/popup.html deleted file mode 100644 index 3ba7919e..00000000 --- a/apps/readest-app/extension/send-to-readest/popup.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - -

Send to Readest

-

Loading…

- -

- - - diff --git a/apps/readest-app/extension/send-to-readest/popup.js b/apps/readest-app/extension/send-to-readest/popup.js deleted file mode 100644 index 87716c76..00000000 --- a/apps/readest-app/extension/send-to-readest/popup.js +++ /dev/null @@ -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(); diff --git a/apps/readest-app/extensions/send-to-readest/.gitignore b/apps/readest-app/extensions/send-to-readest/.gitignore new file mode 100644 index 00000000..f0183443 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/.gitignore @@ -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 diff --git a/apps/readest-app/extensions/send-to-readest/README.md b/apps/readest-app/extensions/send-to-readest/README.md new file mode 100644 index 00000000..ebb0c374 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/README.md @@ -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`, + ``/``, `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: [""]` — 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/.`, deduplicated by + hash so a hero shared between `` and `` 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 /, 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 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/.json` | 29 runtime UI strings — popup, errors, status, badges. `{ "": "" }`. | 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//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/.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//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/.json` populated with `__STRING_NOT_TRANSLATED__` + placeholders, regenerates `src/locales/index.ts`. +3. Hand-translate each value. +4. *(Optional)* Drop `_locales//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). diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ar/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ar/messages.json new file mode 100644 index 00000000..603424eb --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ar/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/bn/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/bn/messages.json new file mode 100644 index 00000000..1dd17974 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/bn/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/bo/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/bo/messages.json new file mode 100644 index 00000000..3e28a9a4 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/bo/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/de/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/de/messages.json new file mode 100644 index 00000000..4a0af237 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/de/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/el/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/el/messages.json new file mode 100644 index 00000000..138d72ae --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/el/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/en/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/en/messages.json new file mode 100644 index 00000000..f9f622fb --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/en/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/es/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/es/messages.json new file mode 100644 index 00000000..de2e46af --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/es/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/fa/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/fa/messages.json new file mode 100644 index 00000000..6ed1e4bc --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/fa/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/fr/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/fr/messages.json new file mode 100644 index 00000000..4c2e8149 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/fr/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/he/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/he/messages.json new file mode 100644 index 00000000..2877c220 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/he/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/hi/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/hi/messages.json new file mode 100644 index 00000000..b589fea7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/hi/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/hu/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/hu/messages.json new file mode 100644 index 00000000..ee0ebfd6 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/hu/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/id/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/id/messages.json new file mode 100644 index 00000000..a72a9485 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/id/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/it/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/it/messages.json new file mode 100644 index 00000000..d14608c4 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/it/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ja/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ja/messages.json new file mode 100644 index 00000000..0dcd83c7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ja/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ko/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ko/messages.json new file mode 100644 index 00000000..99e4cdd7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ko/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ms/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ms/messages.json new file mode 100644 index 00000000..2308e25a --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ms/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/nl/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/nl/messages.json new file mode 100644 index 00000000..6e26e754 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/nl/messages.json @@ -0,0 +1,14 @@ +{ + "app_name": { + "message": "Naar Readest sturen", + "description": "Extension name shown in the browser toolbar and the Chrome Web Store listing." + }, + "app_description": { + "message": "Sla webpagina’s op in je Readest-bibliotheek", + "description": "Short description shown in the Chrome Web Store listing." + }, + "action_title": { + "message": "Naar Readest sturen", + "description": "Tooltip on the toolbar action button." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/pl/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/pl/messages.json new file mode 100644 index 00000000..8a87fd9d --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/pl/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/pt-BR/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/pt-BR/messages.json new file mode 100644 index 00000000..ae73e493 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/pt-BR/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/pt/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/pt/messages.json new file mode 100644 index 00000000..f4f9cecd --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/pt/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ro/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ro/messages.json new file mode 100644 index 00000000..1e678f2c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ro/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ru/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ru/messages.json new file mode 100644 index 00000000..1851d030 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ru/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/si/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/si/messages.json new file mode 100644 index 00000000..c45529c4 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/si/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/sl/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/sl/messages.json new file mode 100644 index 00000000..f4a3400f --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/sl/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/sv/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/sv/messages.json new file mode 100644 index 00000000..bd83d7f2 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/sv/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/ta/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/ta/messages.json new file mode 100644 index 00000000..23a4c8df --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/ta/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/th/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/th/messages.json new file mode 100644 index 00000000..26f40707 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/th/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/tr/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/tr/messages.json new file mode 100644 index 00000000..b1d5da57 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/tr/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/uk/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/uk/messages.json new file mode 100644 index 00000000..59d273d1 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/uk/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/uz/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/uz/messages.json new file mode 100644 index 00000000..bf8c7483 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/uz/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/vi/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/vi/messages.json new file mode 100644 index 00000000..999bb8db --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/vi/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/zh-CN/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/zh-CN/messages.json new file mode 100644 index 00000000..9ea22827 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/zh-CN/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/_locales/zh-TW/messages.json b/apps/readest-app/extensions/send-to-readest/_locales/zh-TW/messages.json new file mode 100644 index 00000000..424f4919 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/_locales/zh-TW/messages.json @@ -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." + } +} diff --git a/apps/readest-app/extensions/send-to-readest/icons/icon-128.png b/apps/readest-app/extensions/send-to-readest/icons/icon-128.png new file mode 100644 index 00000000..f90ae3bf Binary files /dev/null and b/apps/readest-app/extensions/send-to-readest/icons/icon-128.png differ diff --git a/apps/readest-app/extensions/send-to-readest/icons/icon-16.png b/apps/readest-app/extensions/send-to-readest/icons/icon-16.png new file mode 100644 index 00000000..6e75a2e4 Binary files /dev/null and b/apps/readest-app/extensions/send-to-readest/icons/icon-16.png differ diff --git a/apps/readest-app/extensions/send-to-readest/icons/icon-256.png b/apps/readest-app/extensions/send-to-readest/icons/icon-256.png new file mode 100644 index 00000000..996eabad Binary files /dev/null and b/apps/readest-app/extensions/send-to-readest/icons/icon-256.png differ diff --git a/apps/readest-app/extensions/send-to-readest/icons/icon-32.png b/apps/readest-app/extensions/send-to-readest/icons/icon-32.png new file mode 100644 index 00000000..05d12854 Binary files /dev/null and b/apps/readest-app/extensions/send-to-readest/icons/icon-32.png differ diff --git a/apps/readest-app/extensions/send-to-readest/icons/icon-48.png b/apps/readest-app/extensions/send-to-readest/icons/icon-48.png new file mode 100644 index 00000000..d4e80f2c Binary files /dev/null and b/apps/readest-app/extensions/send-to-readest/icons/icon-48.png differ diff --git a/apps/readest-app/extensions/send-to-readest/manifest.json b/apps/readest-app/extensions/send-to-readest/manifest.json new file mode 100644 index 00000000..164519e9 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/manifest.json @@ -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": [""], + "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" + } + ] +} diff --git a/apps/readest-app/extensions/send-to-readest/offscreen.html b/apps/readest-app/extensions/send-to-readest/offscreen.html new file mode 100644 index 00000000..6db828a5 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/offscreen.html @@ -0,0 +1,14 @@ + + + + + Send to Readest — converter + + + + + + diff --git a/apps/readest-app/extensions/send-to-readest/package.json b/apps/readest-app/extensions/send-to-readest/package.json new file mode 100644 index 00000000..7684bcdc --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/package.json @@ -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" + } +} diff --git a/apps/readest-app/extensions/send-to-readest/popup.html b/apps/readest-app/extensions/send-to-readest/popup.html new file mode 100644 index 00000000..b4c05668 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/popup.html @@ -0,0 +1,205 @@ + + + + + Send to Readest + + + +
+

+ +
+ +
+
+

+

+
+ +
+
+
+
+

+
+ + + + + + diff --git a/apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs b/apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs new file mode 100644 index 00000000..21053f15 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs @@ -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//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; + +${importLines.join('\n')} + +export const bundles: Record = { +${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//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(); diff --git a/apps/readest-app/extensions/send-to-readest/src/__test-utils__/chromeMock.ts b/apps/readest-app/extensions/send-to-readest/src/__test-utils__/chromeMock.ts new file mode 100644 index 00000000..ef9a9fb7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/__test-utils__/chromeMock.ts @@ -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>, 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(); + const sessionStore = new Map(); + + const store = (s: Map) => ({ + get: vi.fn(async (key?: string | string[]) => { + if (key === undefined) { + return Object.fromEntries(s); + } + const keys = Array.isArray(key) ? key : [key]; + const out: Record = {}; + for (const k of keys) { + if (s.has(k)) out[k] = s.get(k); + } + return out; + }), + set: vi.fn(async (entries: Record) => { + 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; +} diff --git a/apps/readest-app/extensions/send-to-readest/src/background/badge.test.ts b/apps/readest-app/extensions/send-to-readest/src/background/badge.test.ts new file mode 100644 index 00000000..e3123929 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/badge.test.ts @@ -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', + }); + }); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/background/badge.ts b/apps/readest-app/extensions/send-to-readest/src/background/badge.ts new file mode 100644 index 00000000..3052683c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/badge.ts @@ -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, string> = { + cap: '#1a73e8', + img: '#1a73e8', + epub: '#1a73e8', + send: '#1a73e8', + ok: '#1e8e3e', + err: '#c0392b', +}; + +const LABELS: Record, 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); +} diff --git a/apps/readest-app/extensions/send-to-readest/src/background/offscreen.ts b/apps/readest-app/extensions/send-to-readest/src/background/offscreen.ts new file mode 100644 index 00000000..1cd4ca59 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/offscreen.ts @@ -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 | null = null; + +async function offscreenDocumentExists(): Promise { + const off = chrome.offscreen as typeof chrome.offscreen & { + hasDocument?: () => Promise; + }; + if (off?.hasDocument) { + try { + return await off.hasDocument(); + } catch { + /* fall through */ + } + } + const runtime = chrome.runtime as typeof chrome.runtime & { + getContexts?: (filter: { contextTypes: string[] }) => Promise; + }; + if (runtime.getContexts) { + const contexts = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }); + return contexts.length > 0; + } + return false; +} + +export async function ensureOffscreenDocument(): Promise { + if (await offscreenDocumentExists()) { + await waitForOffscreenReady(); + return; + } + if (ensurePromise) return ensurePromise; + + ensurePromise = (async (): Promise => { + 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 { + // ~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((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((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, + }, + }; +} diff --git a/apps/readest-app/extensions/send-to-readest/src/background/service-worker.ts b/apps/readest-app/extensions/send-to-readest/src/background/service-worker.ts new file mode 100644 index 00000000..eba2a9b4 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/service-worker.ts @@ -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(); + +async function injectCapture(tabId: number): Promise { + const waitForResult = new Promise((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 { + 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 => { + 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; +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/background/upload.test.ts b/apps/readest-app/extensions/send-to-readest/src/background/upload.test.ts new file mode 100644 index 00000000..e47f0c4c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/upload.test.ts @@ -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; + +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; + expect(headers['Authorization']).toBe('Bearer tok-abc'); + expect(headers['Content-Type']).toBe('application/epub+zip'); + // RFC 5987: `UTF-8''` 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; + 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'); + } + }); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/background/upload.ts b/apps/readest-app/extensions/send-to-readest/src/background/upload.ts new file mode 100644 index 00000000..e208da82 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/background/upload.ts @@ -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 { + 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 { + 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 ?? '' }; +} diff --git a/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.test.ts b/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.test.ts new file mode 100644 index 00000000..930a5e9c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.test.ts @@ -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; + +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; + 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)[ + '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)[ + 'readestAccessToken' + ], + ).toBe('new-token'); + }); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.ts b/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.ts new file mode 100644 index 00000000..79ce1614 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.ts @@ -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(); + } +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/content/capture.ts b/apps/readest-app/extensions/send-to-readest/src/content/capture.ts new file mode 100644 index 00000000..ceeb0209 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/content/capture.ts @@ -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('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(); diff --git a/apps/readest-app/extensions/send-to-readest/src/lib/auth.test.ts b/apps/readest-app/extensions/send-to-readest/src/lib/auth.test.ts new file mode 100644 index 00000000..1c2c58d6 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/lib/auth.test.ts @@ -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(); + } + }); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/lib/auth.ts b/apps/readest-app/extensions/send-to-readest/src/lib/auth.ts new file mode 100644 index 00000000..8b4567ad --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/lib/auth.ts @@ -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 { + 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 { + await chrome.storage.local.set({ + [TOKEN_KEY]: token, + [TOKEN_AT_KEY]: Date.now(), + }); +} + +export async function clearToken(): Promise { + await chrome.storage.local.remove([TOKEN_KEY, TOKEN_AT_KEY]); +} diff --git a/apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts b/apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts new file mode 100644 index 00000000..d414f269 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts @@ -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/.json` (locale = short + * code from `chrome.i18n.getUILanguage()`). The English bundle is + * literally `{}` — fall-through to the key. Non-English bundles are + * `{ "": "" }`. + * + * 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; + +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)) { + 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 = 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 { + 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('[data-i18n]'))) { + const source = el.dataset['i18n']; + if (source) el.textContent = translate(source); + } + for (const el of Array.from(root.querySelectorAll('[data-i18n-title]'))) { + const source = el.dataset['i18nTitle']; + if (source) el.title = translate(source); + } +} diff --git a/apps/readest-app/extensions/send-to-readest/src/lib/messages.ts b/apps/readest-app/extensions/send-to-readest/src/lib/messages.ts new file mode 100644 index 00000000..2b41be36 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/lib/messages.ts @@ -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; +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ar.json b/apps/readest-app/extensions/send-to-readest/src/locales/ar.json new file mode 100644 index 00000000..fb8a4e60 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ar.json @@ -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": "خطأ غير معروف" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/bn.json b/apps/readest-app/extensions/send-to-readest/src/locales/bn.json new file mode 100644 index 00000000..aafe7e95 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/bn.json @@ -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": "অজানা ত্রুটি" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/bo.json b/apps/readest-app/extensions/send-to-readest/src/locales/bo.json new file mode 100644 index 00000000..d384df1a --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/bo.json @@ -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": "ངོ་མ་ཤེས་པའི་སྐྱོན།" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/de.json b/apps/readest-app/extensions/send-to-readest/src/locales/de.json new file mode 100644 index 00000000..e862cdab --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/de.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/el.json b/apps/readest-app/extensions/send-to-readest/src/locales/el.json new file mode 100644 index 00000000..6e5735dc --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/el.json @@ -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": "Άγνωστο σφάλμα" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/en.json b/apps/readest-app/extensions/send-to-readest/src/locales/en.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/es.json b/apps/readest-app/extensions/send-to-readest/src/locales/es.json new file mode 100644 index 00000000..b713eb55 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/es.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/fa.json b/apps/readest-app/extensions/send-to-readest/src/locales/fa.json new file mode 100644 index 00000000..8b0c4a03 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/fa.json @@ -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": "خطای نامشخص" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/fr.json b/apps/readest-app/extensions/send-to-readest/src/locales/fr.json new file mode 100644 index 00000000..52c0545e --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/fr.json @@ -0,0 +1,31 @@ +{ + "Article is too large to send": "L'article est trop volumineux pour être envoyé", + "Building EPUB…": "Création de l’EPUB…", + "Capture script could not start on this page": "Le script de capture n’a pas pu démarrer sur cette page", + "Conversion timed out after {seconds}s": "Conversion expirée après {seconds} s", + "Could not inject capture script: {reason}": "Impossible d’injecter le script de capture : {reason}", + "Could not reach {host} — {reason}": "Impossible d’atteindre {host} — {reason}", + "Could not start the converter page: {reason}": "Impossible de démarrer la page du convertisseur : {reason}", + "Couldn't read this page": "Impossible de lire cette page", + "Inbox is full": "La boîte de réception est pleine", + "Loading…": "Chargement…", + "No active tab": "Aucun onglet actif", + "Offscreen page failed to start": "Le démarrage de la page du convertisseur a échoué", + "Open web.readest.com": "Ouvrir web.readest.com", + "Page took too long to read": "La page a mis trop de temps à être lue", + "Reading page…": "Lecture de la page…", + "Send to Readest": "Envoyer vers Readest", + "Sending to Readest…": "Envoi vers Readest…", + "Sent — {count} images could not be fetched.": "Envoyé — {count} images n’ont pas pu être récupérées.", + "Sent — 1 image could not be fetched.": "Envoyé — 1 image n’a pas pu être récupérée.", + "Sent — it will appear in your library shortly.": "Envoyé — l’article apparaîtra bientôt dans votre bibliothèque.", + "Server returned {status}": "Le serveur a renvoyé {status}", + "Session expired": "Session expirée", + "Sign in at web.readest.com first": "Connectez-vous d’abord sur web.readest.com", + "Sign in to Readest to start clipping pages.": "Connectez-vous à Readest pour commencer à enregistrer des pages.", + "Signed out": "Déconnecté", + "Starting…": "Démarrage…", + "This page cannot be clipped": "Cette page ne peut pas être enregistrée", + "Unexpected server response": "Réponse inattendue du serveur", + "Unknown error": "Erreur inconnue" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/he.json b/apps/readest-app/extensions/send-to-readest/src/locales/he.json new file mode 100644 index 00000000..89c44a7b --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/he.json @@ -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": "שגיאה לא ידועה" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/hi.json b/apps/readest-app/extensions/send-to-readest/src/locales/hi.json new file mode 100644 index 00000000..a1794cc8 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/hi.json @@ -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": "अज्ञात त्रुटि" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/hu.json b/apps/readest-app/extensions/send-to-readest/src/locales/hu.json new file mode 100644 index 00000000..49ccebeb --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/hu.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/id.json b/apps/readest-app/extensions/send-to-readest/src/locales/id.json new file mode 100644 index 00000000..c3899dd8 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/id.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/index.ts b/apps/readest-app/extensions/send-to-readest/src/locales/index.ts new file mode 100644 index 00000000..af5fc0c3 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/index.ts @@ -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; + +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 = { + 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, +}; diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/it.json b/apps/readest-app/extensions/send-to-readest/src/locales/it.json new file mode 100644 index 00000000..b0d0dfe3 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/it.json @@ -0,0 +1,31 @@ +{ + "Article is too large to send": "L'articolo è troppo grande per essere inviato", + "Building EPUB…": "Creazione dell’EPUB…", + "Capture script could not start on this page": "Lo script di acquisizione non è riuscito ad avviarsi su questa pagina", + "Conversion timed out after {seconds}s": "Conversione scaduta dopo {seconds} s", + "Could not inject capture script: {reason}": "Impossibile iniettare lo script di acquisizione: {reason}", + "Could not reach {host} — {reason}": "Impossibile contattare {host} — {reason}", + "Could not start the converter page: {reason}": "Impossibile avviare la pagina del convertitore: {reason}", + "Couldn't read this page": "Impossibile leggere questa pagina", + "Inbox is full": "La posta in arrivo è piena", + "Loading…": "Caricamento…", + "No active tab": "Nessuna scheda attiva", + "Offscreen page failed to start": "Avvio della pagina del convertitore non riuscito", + "Open web.readest.com": "Apri web.readest.com", + "Page took too long to read": "La pagina ha impiegato troppo tempo per essere letta", + "Reading page…": "Lettura della pagina…", + "Send to Readest": "Invia a Readest", + "Sending to Readest…": "Invio a Readest…", + "Sent — {count} images could not be fetched.": "Inviato — impossibile recuperare {count} immagini.", + "Sent — 1 image could not be fetched.": "Inviato — impossibile recuperare 1 immagine.", + "Sent — it will appear in your library shortly.": "Inviato — apparirà a breve nella tua libreria.", + "Server returned {status}": "Il server ha restituito {status}", + "Session expired": "Sessione scaduta", + "Sign in at web.readest.com first": "Accedi prima a web.readest.com", + "Sign in to Readest to start clipping pages.": "Accedi a Readest per iniziare a salvare le pagine.", + "Signed out": "Disconnesso", + "Starting…": "Avvio…", + "This page cannot be clipped": "Questa pagina non può essere salvata", + "Unexpected server response": "Risposta del server imprevista", + "Unknown error": "Errore sconosciuto" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ja.json b/apps/readest-app/extensions/send-to-readest/src/locales/ja.json new file mode 100644 index 00000000..ad44148f --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ja.json @@ -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": "不明なエラー" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ko.json b/apps/readest-app/extensions/send-to-readest/src/locales/ko.json new file mode 100644 index 00000000..3b2e7a97 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ko.json @@ -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": "알 수 없는 오류" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ms.json b/apps/readest-app/extensions/send-to-readest/src/locales/ms.json new file mode 100644 index 00000000..00bd4197 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ms.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/nl.json b/apps/readest-app/extensions/send-to-readest/src/locales/nl.json new file mode 100644 index 00000000..22f9adc7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/nl.json @@ -0,0 +1,31 @@ +{ + "Article is too large to send": "Artikel is te groot om te versturen", + "Building EPUB…": "EPUB wordt opgebouwd…", + "Capture script could not start on this page": "Het opnamescript kon niet starten op deze pagina", + "Conversion timed out after {seconds}s": "Conversie verlopen na {seconds} s", + "Could not inject capture script: {reason}": "Opnamescript kan niet worden geïnjecteerd: {reason}", + "Could not reach {host} — {reason}": "Kan {host} niet bereiken — {reason}", + "Could not start the converter page: {reason}": "Kan de converter-pagina niet starten: {reason}", + "Couldn't read this page": "Deze pagina kan niet worden gelezen", + "Inbox is full": "Postvak is vol", + "Loading…": "Laden…", + "No active tab": "Geen actief tabblad", + "Offscreen page failed to start": "Converter-pagina kon niet starten", + "Open web.readest.com": "web.readest.com openen", + "Page took too long to read": "Het lezen van de pagina duurde te lang", + "Reading page…": "Pagina lezen…", + "Send to Readest": "Naar Readest sturen", + "Sending to Readest…": "Verzenden naar Readest…", + "Sent — {count} images could not be fetched.": "Verzonden — {count} afbeeldingen konden niet worden opgehaald.", + "Sent — 1 image could not be fetched.": "Verzonden — 1 afbeelding kon niet worden opgehaald.", + "Sent — it will appear in your library shortly.": "Verzonden — verschijnt binnenkort in je bibliotheek.", + "Server returned {status}": "Server gaf {status} terug", + "Session expired": "Sessie verlopen", + "Sign in at web.readest.com first": "Meld je eerst aan op web.readest.com", + "Sign in to Readest to start clipping pages.": "Meld je aan bij Readest om pagina’s op te slaan.", + "Signed out": "Afgemeld", + "Starting…": "Starten…", + "This page cannot be clipped": "Deze pagina kan niet worden opgeslagen", + "Unexpected server response": "Onverwacht antwoord van server", + "Unknown error": "Onbekende fout" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/pl.json b/apps/readest-app/extensions/send-to-readest/src/locales/pl.json new file mode 100644 index 00000000..71fc8a29 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/pl.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/pt-BR.json b/apps/readest-app/extensions/send-to-readest/src/locales/pt-BR.json new file mode 100644 index 00000000..1f1dafd1 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/pt-BR.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/pt.json b/apps/readest-app/extensions/send-to-readest/src/locales/pt.json new file mode 100644 index 00000000..b0624f57 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/pt.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ro.json b/apps/readest-app/extensions/send-to-readest/src/locales/ro.json new file mode 100644 index 00000000..31cef07c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ro.json @@ -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ă" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ru.json b/apps/readest-app/extensions/send-to-readest/src/locales/ru.json new file mode 100644 index 00000000..61082980 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ru.json @@ -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": "Неизвестная ошибка" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/si.json b/apps/readest-app/extensions/send-to-readest/src/locales/si.json new file mode 100644 index 00000000..274c6d00 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/si.json @@ -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": "නොදන්නා දෝෂය" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/sl.json b/apps/readest-app/extensions/send-to-readest/src/locales/sl.json new file mode 100644 index 00000000..7f4ead5f --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/sl.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/sv.json b/apps/readest-app/extensions/send-to-readest/src/locales/sv.json new file mode 100644 index 00000000..a5c1c07c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/sv.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/ta.json b/apps/readest-app/extensions/send-to-readest/src/locales/ta.json new file mode 100644 index 00000000..aaac284a --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/ta.json @@ -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": "அறியப்படாத பிழை" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/th.json b/apps/readest-app/extensions/send-to-readest/src/locales/th.json new file mode 100644 index 00000000..ae1b2db9 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/th.json @@ -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": "ข้อผิดพลาดที่ไม่รู้จัก" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/tr.json b/apps/readest-app/extensions/send-to-readest/src/locales/tr.json new file mode 100644 index 00000000..d463844f --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/tr.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/uk.json b/apps/readest-app/extensions/send-to-readest/src/locales/uk.json new file mode 100644 index 00000000..42da1184 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/uk.json @@ -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": "Невідома помилка" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/uz.json b/apps/readest-app/extensions/send-to-readest/src/locales/uz.json new file mode 100644 index 00000000..acf2398e --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/uz.json @@ -0,0 +1,31 @@ +{ + "Article is too large to send": "Maqola yuborish uchun juda katta", + "Building EPUB…": "EPUB yaratilmoqda…", + "Capture script could not start on this page": "Saqlash skripti ushbu sahifada ishga tushmadi", + "Conversion timed out after {seconds}s": "Aylantirish {seconds} soniyadan keyin tugadi", + "Could not inject capture script: {reason}": "Saqlash skriptini joriy etib bo'lmadi: {reason}", + "Could not reach {host} — {reason}": "{host} bilan bog'lanib bo'lmadi — {reason}", + "Could not start the converter page: {reason}": "Aylantirgich sahifasini boshlab bo'lmadi: {reason}", + "Couldn't read this page": "Ushbu sahifani o'qib bo'lmadi", + "Inbox is full": "Kiruvchi qutisi to'la", + "Loading…": "Yuklanmoqda…", + "No active tab": "Faol yorliq yo‘q", + "Offscreen page failed to start": "Aylantirgich sahifasi ishga tushmadi", + "Open web.readest.com": "web.readest.com sahifasini ochish", + "Page took too long to read": "Sahifani o‘qish juda uzoq vaqt oldi", + "Reading page…": "Sahifa o‘qilmoqda…", + "Send to Readest": "Readest'ga yuborish", + "Sending to Readest…": "Readest'ga yuborilmoqda…", + "Sent — {count} images could not be fetched.": "Yuborildi — {count} ta rasmni olib bo‘lmadi.", + "Sent — 1 image could not be fetched.": "Yuborildi — 1 ta rasmni olib bo‘lmadi.", + "Sent — it will appear in your library shortly.": "Yuborildi — tez orada kutubxonangizda paydo bo‘ladi.", + "Server returned {status}": "Server {status} qaytardi", + "Session expired": "Seans muddati tugadi", + "Sign in at web.readest.com first": "Avval web.readest.com saytiga kiring", + "Sign in to Readest to start clipping pages.": "Sahifalarni saqlashni boshlash uchun Readest'ga kiring.", + "Signed out": "Tizimdan chiqildi", + "Starting…": "Boshlanyapti…", + "This page cannot be clipped": "Bu sahifani saqlab bo‘lmaydi", + "Unexpected server response": "Kutilmagan server javobi", + "Unknown error": "Noma'lum xato" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/vi.json b/apps/readest-app/extensions/send-to-readest/src/locales/vi.json new file mode 100644 index 00000000..b3361698 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/vi.json @@ -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" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/zh-CN.json b/apps/readest-app/extensions/send-to-readest/src/locales/zh-CN.json new file mode 100644 index 00000000..772b23aa --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/zh-CN.json @@ -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": "未知错误" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/locales/zh-TW.json b/apps/readest-app/extensions/send-to-readest/src/locales/zh-TW.json new file mode 100644 index 00000000..e7205e42 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/locales/zh-TW.json @@ -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": "未知的錯誤" +} diff --git a/apps/readest-app/extensions/send-to-readest/src/offscreen/offscreen.ts b/apps/readest-app/extensions/send-to-readest/src/offscreen/offscreen.ts new file mode 100644 index 00000000..7de8ac5c --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/offscreen/offscreen.ts @@ -0,0 +1,110 @@ +/** + * Offscreen-document host. Runs `convertToEpub({kind:'page'})` (needs DOMParser / + * Document, which an MV3 service worker lacks) AND the upload to + * `/api/send/inbox/file`. + * + * Why upload from the offscreen page instead of returning the EPUB bytes + * to the SW: `chrome.runtime.sendMessage` does not reliably structured- + * clone `ArrayBuffer` between extension contexts in current Chrome — + * large buffers come through JSON-serialized, which collapses an + * `ArrayBuffer` to `{}`. Wrapping that in `new Blob([{}])` yields the + * literal text "[object Object]", and the inbox drainer rightly rejects + * it as a corrupt EPUB. Uploading from the page that produced the bytes + * keeps the EPUB in one realm. + * + * Protocol: + * SW → offscreen: { type:'send-to-readest:clip-and-upload', + * html, url, token } + * offscreen → SW: { ok:true, inboxId, title, author, byteSize } + * or { ok:false, code, message } + */ + +import { configureZip } from '@/utils/zip'; +import { convertToEpub } from '@/services/send/conversion/convertToEpub'; +import type { ClipErrorCode } from '../lib/messages'; +import { uploadEpub } from '../background/upload'; + +const LOG = '[send-to-readest/offscreen]'; + +configureZip().catch((err) => console.warn(LOG, 'configureZip failed', err)); + +// Cheap liveness probe so the SW can confirm this page is ready to +// accept work, rather than racing `chrome.offscreen.createDocument`'s +// resolution against script execution. +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:ping') return false; + sendResponse({ ok: true }); + return false; +}); + +interface ClipAndUploadRequest { + type: 'send-to-readest:clip-and-upload'; + html: string; + url: string; + token: string; + pageTitle: string; + /** Resolved upload URL — the SW reads `chrome.storage.local.readestApiBase` + * and computes this. Passed in so the offscreen page never touches + * `chrome.storage` (observed `undefined` in some Chrome builds). */ + endpoint: string; +} + +type ClipAndUploadResponse = + | { ok: true; inboxId: string; title: string; author: string; byteSize: number } + | { ok: false; code: ClipErrorCode; message: string }; + +chrome.runtime.onMessage.addListener( + (message: unknown, _sender, sendResponse: (response: ClipAndUploadResponse) => void): boolean => { + if (!message || typeof message !== 'object') return false; + const m = message as { type?: string }; + if (m.type !== 'send-to-readest:clip-and-upload') return false; + + const req = message as ClipAndUploadRequest; + + void (async (): Promise => { + let converted; + try { + converted = await convertToEpub({ kind: 'page', html: req.html, url: req.url }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.warn(LOG, 'convert failed', error); + sendResponse({ ok: false, code: 'no-readable-content', message: error }); + return; + } + + try { + const result = await uploadEpub({ + endpoint: req.endpoint, + token: req.token, + epub: converted.file, + title: converted.title || req.pageTitle, + sourceUrl: req.url, + }); + + if (!result.ok) { + sendResponse({ ok: false, code: result.code, message: result.message }); + return; + } + sendResponse({ + ok: true, + inboxId: result.id, + title: converted.title, + author: converted.author, + byteSize: converted.file.size, + }); + } catch (err) { + // `uploadEpub` is supposed to swallow its own errors — but if a + // sync throw slips through (e.g. a URL parse on a malformed + // endpoint), we still must call sendResponse, or the SW hangs + // forever on the `chrome.runtime.sendMessage` Promise. + const error = err instanceof Error ? err.message : String(err); + console.warn(LOG, 'upload threw unexpectedly', error); + sendResponse({ ok: false, code: 'unknown', message: error }); + } + })(); + + return true; + }, +); diff --git a/apps/readest-app/extensions/send-to-readest/src/popup/popup.test.ts b/apps/readest-app/extensions/send-to-readest/src/popup/popup.test.ts new file mode 100644 index 00000000..bb2fd31a --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/popup/popup.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + installChromeMock, + uninstallChromeMock, + type ChromeMock, +} from '../__test-utils__/chromeMock'; +import type { ClipProgress, StatusResponse } from '../lib/messages'; + +/** + * `popup.ts` runs `init()` synchronously at module load — it queries + * tabs, asks the SW for status, and grabs DOM nodes by id. We re-build + * the DOM scaffold before each test and `vi.resetModules()` so each + * case sees a fresh popup wiring. + */ + +const POPUP_DOM = ` +
+

Send to Readest

+ +
+
+
+

Loading…

+

+
+ +
+
Preparing…
+
+
+

+
+ +`; + +let chromeMock: ChromeMock; +let progressHandler: ((message: unknown) => void) | null = null; + +function setStatus(overrides: Partial = {}): void { + chromeMock.runtime.sendMessage.mockImplementation(async (msg: unknown) => { + if ( + msg && + typeof msg === 'object' && + (msg as { type?: string }).type === 'send-to-readest:status' + ) { + return { + signedIn: true, + inFlight: false, + lastProgress: null, + ...overrides, + } satisfies StatusResponse; + } + return undefined; + }); +} + +beforeEach(() => { + document.body.innerHTML = POPUP_DOM; + chromeMock = installChromeMock(); + chromeMock.tabs.query.mockResolvedValue([ + { id: 7, url: 'https://example.com/article', title: 'Hello Article' }, + ] as unknown as chrome.tabs.Tab[]); + setStatus(); + progressHandler = null; + chromeMock.runtime.onMessage.addListener.mockImplementation((handler: (m: unknown) => void) => { + progressHandler = handler; + }); + vi.resetModules(); +}); + +afterEach(() => { + uninstallChromeMock(); + document.body.innerHTML = ''; +}); + +async function loadPopup(): Promise { + await import('./popup'); + // init() is async; let its promise chain settle before assertions. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function pushProgress(progress: ClipProgress): void { + if (!progressHandler) throw new Error('progress handler never registered'); + progressHandler({ type: 'send-to-readest:progress', progress }); +} + +describe('popup — initial render', () => { + test('renders the active tab title + url and enables Send when signed in', async () => { + await loadPopup(); + + expect(document.getElementById('page-title')?.textContent).toBe('Hello Article'); + expect(document.getElementById('page-url')?.textContent).toBe('https://example.com/article'); + expect(document.getElementById('signed-in-view')?.classList.contains('hidden')).toBe(false); + expect((document.getElementById('send') as HTMLButtonElement).disabled).toBe(false); + }); + + test('routes to the signed-out view when the SW reports no token', async () => { + setStatus({ signedIn: false }); + await loadPopup(); + + expect(document.getElementById('signed-in-view')?.classList.contains('hidden')).toBe(true); + expect(document.getElementById('signed-out-view')?.classList.contains('hidden')).toBe(false); + }); + + test('refuses to clip non-http URLs', async () => { + chromeMock.tabs.query.mockResolvedValue([ + { id: 1, url: 'chrome://extensions', title: 'Extensions' }, + ] as unknown as chrome.tabs.Tab[]); + await loadPopup(); + expect(document.getElementById('page-title')?.textContent).toBe('This page cannot be clipped'); + expect((document.getElementById('send') as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe('popup — render(progress)', () => { + test('"capturing" disables Send and shows the reading label', async () => { + await loadPopup(); + pushProgress({ phase: 'capturing' }); + expect(document.getElementById('progress-label')?.textContent).toBe('Reading page…'); + expect((document.getElementById('send') as HTMLButtonElement).disabled).toBe(true); + expect(document.getElementById('progress')?.classList.contains('show')).toBe(true); + }); + + test('"converting" shows the EPUB-build label', async () => { + await loadPopup(); + pushProgress({ phase: 'converting' }); + expect(document.getElementById('progress-label')?.textContent).toBe('Building EPUB…'); + }); + + test('"uploading" shows the send-to-Readest label', async () => { + await loadPopup(); + pushProgress({ phase: 'uploading' }); + expect(document.getElementById('progress-label')?.textContent).toBe('Sending to Readest…'); + }); + + test('"done" hides progress, re-enables Send, shows success status', async () => { + await loadPopup(); + pushProgress({ phase: 'done' }); + expect(document.getElementById('progress')?.classList.contains('show')).toBe(false); + expect((document.getElementById('send') as HTMLButtonElement).disabled).toBe(false); + const status = document.getElementById('status')!; + expect(status.classList.contains('ok')).toBe(true); + expect(status.textContent).toContain('Sent'); + }); + + test('"done" with missingAssets surfaces the image-fetch failure count', async () => { + await loadPopup(); + pushProgress({ phase: 'done', missingAssets: 3 }); + expect(document.getElementById('status')?.textContent).toContain( + '3 images could not be fetched', + ); + }); + + test('"done" with a single missing asset uses singular grammar', async () => { + await loadPopup(); + pushProgress({ phase: 'done', missingAssets: 1 }); + expect(document.getElementById('status')?.textContent).toContain( + '1 image could not be fetched', + ); + }); + + test('"error" surfaces the message and re-enables Send', async () => { + await loadPopup(); + pushProgress({ phase: 'error', code: 'server-error', message: 'Server returned 500' }); + expect((document.getElementById('send') as HTMLButtonElement).disabled).toBe(false); + expect(document.getElementById('status')?.classList.contains('err')).toBe(true); + expect(document.getElementById('status')?.textContent).toBe('Server returned 500'); + }); + + test('"error" with session-expired flips to the signed-out view', async () => { + await loadPopup(); + pushProgress({ phase: 'error', code: 'session-expired', message: 'Session expired' }); + expect(document.getElementById('signed-in-view')?.classList.contains('hidden')).toBe(true); + expect(document.getElementById('signed-out-view')?.classList.contains('hidden')).toBe(false); + }); +}); + +describe('popup — Send button', () => { + test('clicking Send dispatches a clip request with the active tabId', async () => { + await loadPopup(); + chromeMock.runtime.sendMessage.mockClear(); + document.getElementById('send')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const clipCall = chromeMock.runtime.sendMessage.mock.calls.find((c) => { + const m = c[0] as { type?: string }; + return m?.type === 'send-to-readest:clip'; + }); + expect(clipCall).toBeDefined(); + expect(clipCall![0]).toEqual({ type: 'send-to-readest:clip', tabId: 7 }); + }); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/popup/popup.ts b/apps/readest-app/extensions/send-to-readest/src/popup/popup.ts new file mode 100644 index 00000000..07a6ed97 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/popup/popup.ts @@ -0,0 +1,168 @@ +/** + * Popup UI. Thin presentation layer over the service worker — the SW owns + * all state and the popup re-renders on every progress broadcast. + * + * The popup may close at any moment (Chrome auto-dismisses on focus loss), + * so we never persist work here. On re-open we ask the SW for the current + * status and render whatever phase it's in. + */ + +import type { ClipProgress, ClipRequest, StatusRequest, StatusResponse } from '../lib/messages'; +import { localizeDom, translate as _ } from '../lib/i18n'; + +const LOGIN_URL = 'https://web.readest.com/'; + +localizeDom(); + +const $ = (id: string): T => { + const el = document.getElementById(id) as T | null; + if (!el) throw new Error(`#${id} missing`); + return el; +}; + +const signedInView = $('signed-in-view'); +const signedOutView = $('signed-out-view'); +const authBadge = $('auth-badge'); +const pageTitle = $('page-title'); +const pageUrl = $('page-url'); +const sendBtn = $('send'); +const openReadestBtn = $('open-readest'); +const progressEl = $('progress'); +const progressLabel = $('progress-label'); +const progressBar = progressEl.querySelector('.progress-bar') as HTMLDivElement; +const progressFill = progressEl.querySelector('.progress-bar-fill') as HTMLDivElement; +const statusEl = $('status'); + +let currentTabId: number | null = null; + +function setStatus(message: string, kind?: 'ok' | 'err'): void { + statusEl.textContent = message; + statusEl.className = `status ${kind ?? ''}`.trim(); +} + +function showProgress(label: string, determinate?: { done: number; total: number }): void { + progressEl.classList.add('show'); + progressLabel.textContent = label; + if (determinate && determinate.total > 0) { + progressBar.classList.remove('indeterminate'); + progressFill.style.width = `${Math.round((determinate.done / determinate.total) * 100)}%`; + } else { + progressBar.classList.add('indeterminate'); + progressFill.style.width = ''; + } +} + +function hideProgress(): void { + progressEl.classList.remove('show'); +} + +function render(progress: ClipProgress | null): void { + switch (progress?.phase) { + case 'capturing': + sendBtn.disabled = true; + showProgress(_('Reading page…')); + setStatus(''); + break; + case 'converting': + sendBtn.disabled = true; + showProgress(_('Building EPUB…')); + setStatus(''); + break; + case 'uploading': + sendBtn.disabled = true; + showProgress(_('Sending to Readest…')); + setStatus(''); + break; + case 'done': + sendBtn.disabled = false; + hideProgress(); + setStatus(doneMessage(progress.missingAssets ?? 0), 'ok'); + break; + case 'error': + sendBtn.disabled = false; + hideProgress(); + setStatus(progress.message, 'err'); + if (progress.code === 'session-expired' || progress.code === 'not-signed-in') { + showSignedOut(); + } + break; + default: + sendBtn.disabled = false; + hideProgress(); + setStatus(''); + } +} + +function doneMessage(missing: number): string { + if (missing <= 0) return _('Sent — it will appear in your library shortly.'); + if (missing === 1) return _('Sent — 1 image could not be fetched.'); + return _('Sent — {count} images could not be fetched.', { count: missing }); +} + +function showSignedOut(): void { + signedInView.classList.add('hidden'); + signedOutView.classList.remove('hidden'); + authBadge.classList.add('hidden'); +} + +function showSignedIn(): void { + signedInView.classList.remove('hidden'); + signedOutView.classList.add('hidden'); + authBadge.classList.add('hidden'); +} + +async function init(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab) currentTabId = tab.id ?? null; + + if (!tab || !tab.url || !/^https?:/i.test(tab.url)) { + pageTitle.textContent = _('This page cannot be clipped'); + pageUrl.textContent = ''; + sendBtn.disabled = true; + return; + } + + pageTitle.textContent = tab.title || tab.url; + pageUrl.textContent = tab.url; + + const status = await chrome.runtime.sendMessage({ + type: 'send-to-readest:status', + }); + + if (!status.signedIn) { + showSignedOut(); + return; + } + + showSignedIn(); + + if (status.inFlight || status.lastProgress) { + render(status.lastProgress); + } else { + sendBtn.disabled = false; + } +} + +sendBtn.addEventListener('click', async () => { + if (currentTabId === null) return; + sendBtn.disabled = true; + showProgress(_('Starting…')); + const request: ClipRequest = { type: 'send-to-readest:clip', tabId: currentTabId }; + await chrome.runtime.sendMessage(request).catch(() => undefined); +}); + +openReadestBtn.addEventListener('click', () => { + chrome.tabs.create({ url: LOGIN_URL }); +}); + +chrome.runtime.onMessage.addListener((message: unknown): void => { + if (!message || typeof message !== 'object') return; + const m = message as { type?: string; progress?: ClipProgress }; + if (m.type === 'send-to-readest:progress' && m.progress) { + render(m.progress); + } +}); + +init().catch((err: unknown) => { + setStatus(err instanceof Error ? err.message : String(err), 'err'); +}); diff --git a/apps/readest-app/extensions/send-to-readest/src/stubs/environment.ts b/apps/readest-app/extensions/send-to-readest/src/stubs/environment.ts new file mode 100644 index 00000000..727494a7 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/stubs/environment.ts @@ -0,0 +1,13 @@ +/** + * Stub for `@/services/environment` used inside the extension build. The + * conversion modules only consume `isTauriAppPlatform` from this module — + * the extension is never Tauri, so it always returns `false`, which routes + * `assetBundler`/`faviconFetcher` through `globalThis.fetch` (CORS-free in + * the SW thanks to `host_permissions: [""]`). + * + * Other exports from the real `environment.ts` (web-platform checks, base + * URLs, CLI flags) are never reached from the conversion code path, so we + * intentionally don't reproduce them here — the smaller the stub, the + * smaller the bundle. + */ +export const isTauriAppPlatform = (): boolean => false; diff --git a/apps/readest-app/extensions/send-to-readest/src/stubs/tauri-http.ts b/apps/readest-app/extensions/send-to-readest/src/stubs/tauri-http.ts new file mode 100644 index 00000000..45f814b2 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/stubs/tauri-http.ts @@ -0,0 +1,14 @@ +/** + * Stub for `@tauri-apps/plugin-http` used inside the extension build. The + * conversion modules import `tauriFetch` from this package but only invoke + * it inside `isTauriAppPlatform()` branches — and our environment stub + * makes that always false, so this function is never actually called at + * runtime in the extension. + * + * We still need a real export so webpack can resolve the module. Throwing + * if it's somehow reached keeps the bug obvious instead of letting a + * silent no-op corrupt an EPUB. + */ +export const fetch = (): Promise => { + throw new Error('Tauri fetch is not available in the browser extension'); +}; diff --git a/apps/readest-app/extensions/send-to-readest/tsconfig.json b/apps/readest-app/extensions/send-to-readest/tsconfig.json new file mode 100644 index 00000000..46b04259 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "types": ["chrome"], + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@/services/environment": ["./src/stubs/environment.ts"], + "@tauri-apps/plugin-http": ["./src/stubs/tauri-http.ts"], + "@/services/*": ["../../src/services/*"], + "@/utils/*": ["../../src/utils/*"], + "@/types/*": ["../../src/types/*"], + "@/libs/*": ["../../src/libs/*"], + "@/*": ["../../src/*"] + } + }, + "include": ["src/**/*", "../../src/services/send/conversion/**/*", "../../src/utils/zip.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/apps/readest-app/extensions/send-to-readest/webpack.config.js b/apps/readest-app/extensions/send-to-readest/webpack.config.js new file mode 100644 index 00000000..027ff490 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/webpack.config.js @@ -0,0 +1,103 @@ +const path = require('path'); +const CopyPlugin = require('copy-webpack-plugin'); + +// The extension SW imports `convertToEpub` from the readest-app source +// tree so all three clipping channels (desktop, mobile, extension) go +// through one EPUB pipeline and produce byte-identical output for the +// same URL. The aliases below resolve the conversion modules' internal +// `@/` paths and stub the Tauri-only deps they reach for. +const readestSrc = path.resolve(__dirname, '../../src'); +const stubs = path.resolve(__dirname, 'src/stubs'); + +module.exports = (_env, argv) => { + const isProd = argv.mode === 'production'; + return { + mode: isProd ? 'production' : 'development', + devtool: isProd ? false : 'cheap-source-map', + entry: { + 'background/service-worker': './src/background/service-worker.ts', + 'content/capture': './src/content/capture.ts', + 'content/auth-bridge': './src/content/auth-bridge.ts', + 'popup/popup': './src/popup/popup.ts', + // Output the offscreen bundle flat at `dist/offscreen.js` so the + // offscreen.html copied next to it can reference it as `offscreen.js`. + offscreen: './src/offscreen/offscreen.ts', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + alias: { + // Stub Tauri-only deps. The conversion modules guard their use + // behind `isTauriAppPlatform()`, which our environment stub + // always returns false for — so these are never invoked at + // runtime, but webpack still needs them resolvable. + '@tauri-apps/plugin-http': path.resolve(stubs, 'tauri-http.ts'), + // Stub the platform-detection module so the conversion code + // takes its non-Tauri (extension SW) branches. + '@/services/environment': path.resolve(stubs, 'environment.ts'), + // Resolve all other `@/...` paths into the shared readest-app + // source tree. Ordering matters — webpack alias is a prefix + // match, so the specific `@/services/environment` stub above is + // tried first. + '@/services': path.resolve(readestSrc, 'services'), + '@/utils': path.resolve(readestSrc, 'utils'), + '@/types': path.resolve(readestSrc, 'types'), + '@/libs': path.resolve(readestSrc, 'libs'), + '@': readestSrc, + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + // Skip *.test.ts — those are picked up by the root vitest run and + // pull in dev-only imports (zip.js readers etc) that we don't want + // shipped in the production bundle. + exclude: [/node_modules/, /\.test\.ts$/], + use: { + loader: 'ts-loader', + options: { + // Don't run a full type-check inside webpack — `pnpm lint` + // (tsgo) already covers the readest-app side. Webpack just + // needs the JS emit. This also dodges typecheck errors + // from un-aliased ambient types that don't ship to the + // extension bundle. + transpileOnly: true, + }, + }, + }, + ], + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { from: 'manifest.json', to: 'manifest.json' }, + { from: 'popup.html', to: 'popup/popup.html' }, + { from: 'offscreen.html', to: 'offscreen.html' }, + { from: 'icons', to: 'icons', noErrorOnMissing: true }, + // `_locales//messages.json` is Chrome's native i18n + // surface — used for the manifest fields (extension name, + // description, action title) and the Chrome Web Store listing. + { from: '_locales', to: '_locales' }, + ], + }), + ], + optimization: { + // Keep the content script and service worker as single self-contained + // files — Chrome can't load split chunks across content/service-worker + // boundaries without extra plumbing, and the size cost is small. + splitChunks: false, + runtimeChunk: false, + }, + performance: { + // The SW bundles zip.js + Readability + DOMPurify + franc-min for + // language detection (~400 KB minified). The content script is now + // just lazy-load scrolling + a Port handoff (<5 KB). + hints: false, + }, + }; +}; diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 53eed086..a0614179 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -14,17 +14,20 @@ "build-web:vinext": "dotenv -e .env.web -- vinext build", "start-web:vinext": "dotenv -e .env.web -- vinext start", "build-tauri": "dotenv -e .env.tauri -- next build", + "build-browser-ext": "pnpm --filter @readest/send-to-readest-extension build", "dev-android": "tauri android build -t aarch64 -- --features devtools && adb install -r src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk", "dev-ios": "tauri ios build -- --features devtools && ideviceinstaller install src-tauri/gen/apple/build/arm64/Readest.ipa", "i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs", + "i18n:extract:ext": "pnpm --filter @readest/send-to-readest-extension i18n:extract", "lint": "tsgo --noEmit && biome lint . && pnpm lint:lua", "lint:lua": "node ../readest.koplugin/scripts/lint-koplugin.mjs", "test:lua": "node ../readest.koplugin/scripts/test-koplugin.mjs", "test": "dotenv -e .env -e .env.test.local -- vitest", + "test:extension": "pnpm test extensions/send-to-readest/ --run", "test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage", "test:browser": "dotenv -e .env -e .env.test.local -- vitest run --config vitest.browser.config.mts", "test:tauri": "bash scripts/test-tauri.sh", - "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser", + "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser && pnpm test:extension", "test:pr:tauri": "bash scripts/test-tauri.sh", "test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh", "test:e2e": "wdio run wdio.conf.ts", diff --git a/apps/readest-app/src/__tests__/services/send-inbox-file-api.test.ts b/apps/readest-app/src/__tests__/services/send-inbox-file-api.test.ts new file mode 100644 index 00000000..20d43df7 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-inbox-file-api.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +// Hoisted mocks — defined before the route module is imported so vitest +// intercepts the imports inside the route. + +const validateUserMock = vi.fn(); +vi.mock('@/utils/access', () => ({ + validateUserAndToken: (...args: unknown[]) => validateUserMock(...args), +})); + +const corsMock = vi.fn(async () => undefined); +vi.mock('@/utils/cors', () => ({ + corsAllMethods: vi.fn(), + runMiddleware: corsMock, +})); + +const putObjectMock = vi.fn(); +vi.mock('@/utils/object', () => ({ + putObject: (...args: unknown[]) => putObjectMock(...args), +})); + +const insertMock = vi.fn(); +const updateMock = vi.fn(); +const deleteMock = vi.fn(); +const countMock = vi.fn(); +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => ({ + from: () => ({ + select: () => ({ + eq: () => ({ + in: () => countMock(), + }), + }), + insert: (row: unknown) => ({ + select: () => ({ + single: () => insertMock(row), + }), + }), + update: (row: unknown) => ({ + eq: () => updateMock(row), + }), + delete: () => ({ + eq: () => deleteMock(), + }), + }), + }), +})); + +// Import AFTER mocks are in place. +const { default: handler } = await import('@/pages/api/send/inbox/file'); + +interface MockRes { + status: ReturnType; + json: ReturnType; + _status: number; + _body: Record | undefined; +} + +function makeRes(): MockRes { + const res: MockRes = { + status: vi.fn(), + json: vi.fn(), + _status: 0, + _body: undefined, + }; + res.status.mockImplementation((code: number) => { + res._status = code; + return res as unknown as NextApiResponse; + }); + res.json.mockImplementation((body: Record) => { + res._body = body; + return res as unknown as NextApiResponse; + }); + return res; +} + +function makeReq(opts: { + method?: string; + authorization?: string; + contentType?: string; + title?: string; + url?: string; + body?: Buffer; +}): NextApiRequest { + const emitter = new EventEmitter() as EventEmitter & { + headers: Record; + method: string; + destroy: () => void; + }; + emitter.headers = {}; + if (opts.authorization) emitter.headers['authorization'] = opts.authorization; + if (opts.contentType) emitter.headers['content-type'] = opts.contentType; + if (opts.title) emitter.headers['x-readest-title'] = opts.title; + if (opts.url) emitter.headers['x-readest-url'] = opts.url; + emitter.method = opts.method ?? 'POST'; + emitter.destroy = vi.fn(); + + // Emit body asynchronously so handler awaits the stream. + setImmediate(() => { + if (opts.body) emitter.emit('data', opts.body); + emitter.emit('end'); + }); + + return emitter as unknown as NextApiRequest; +} + +const validUser = { id: 'user-123' }; +const VALID_HEADERS = { + authorization: 'Bearer abc', + contentType: 'application/epub+zip', +}; + +beforeEach(() => { + validateUserMock.mockReset().mockResolvedValue({ user: validUser }); + putObjectMock.mockReset().mockResolvedValue(undefined); + countMock.mockReset().mockResolvedValue({ count: 0, error: null }); + insertMock.mockReset().mockResolvedValue({ data: { id: 'inbox-1' }, error: null }); + updateMock.mockReset().mockResolvedValue({ error: null }); + deleteMock.mockReset().mockResolvedValue({ error: null }); + corsMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('POST /api/send/inbox/file', () => { + test('rejects unauthenticated requests with 403', async () => { + validateUserMock.mockResolvedValueOnce({ user: null }); + const req = makeReq({ ...VALID_HEADERS, body: Buffer.from('PK\x03\x04...') }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(403); + }); + + test('rejects non-POST methods with 405', async () => { + const req = makeReq({ method: 'GET', ...VALID_HEADERS }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(405); + }); + + test('rejects unsupported content-types with 415', async () => { + const req = makeReq({ + authorization: 'Bearer abc', + contentType: 'text/html', + body: Buffer.from(''), + }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(415); + }); + + test('rejects invalid source URL with 400', async () => { + const req = makeReq({ + ...VALID_HEADERS, + url: 'javascript:alert(1)', + body: Buffer.from('PK\x03\x04'), + }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(400); + }); + + test('rejects when inbox is full with 429', async () => { + countMock.mockResolvedValueOnce({ count: 60, error: null }); + const req = makeReq({ ...VALID_HEADERS, body: Buffer.from('PK\x03\x04') }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(429); + }); + + test('rejects empty file with 400', async () => { + const req = makeReq({ ...VALID_HEADERS, body: Buffer.alloc(0) }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + expect(res._status).toBe(400); + }); + + test('stores the EPUB and returns the inbox row id on success', async () => { + const body = Buffer.from('PK\x03\x04epub-bytes'); + const req = makeReq({ + ...VALID_HEADERS, + url: 'https://example.com/article', + title: "UTF-8''Article%20%E2%9C%85", + body, + }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + + expect(res._status).toBe(200); + expect(res._body).toEqual({ id: 'inbox-1' }); + + expect(insertMock).toHaveBeenCalledTimes(1); + const insertArg = insertMock.mock.calls[0]![0]; + expect(insertArg).toMatchObject({ + user_id: validUser.id, + kind: 'file', + source: 'extension', + url: 'https://example.com/article', + filename: 'Article ✅', + byte_size: body.byteLength, + }); + + expect(putObjectMock).toHaveBeenCalledTimes(1); + expect(putObjectMock.mock.calls[0]![0]).toBe('inbox/user-123/inbox-1/clip.epub'); + expect(putObjectMock.mock.calls[0]![2]).toBe('application/epub+zip'); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock.mock.calls[0]![0]).toEqual({ + payload_key: 'inbox/user-123/inbox-1/clip.epub', + }); + }); + + test('rolls back the inbox row when R2 put fails', async () => { + putObjectMock.mockRejectedValueOnce(new Error('R2 unavailable')); + const req = makeReq({ ...VALID_HEADERS, body: Buffer.from('PK\x03\x04') }); + const res = makeRes(); + await handler(req, res as unknown as NextApiResponse); + + expect(res._status).toBe(500); + expect(deleteMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/pages/api/send/inbox/file.ts b/apps/readest-app/src/pages/api/send/inbox/file.ts new file mode 100644 index 00000000..ff1fa5e1 --- /dev/null +++ b/apps/readest-app/src/pages/api/send/inbox/file.ts @@ -0,0 +1,170 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { corsAllMethods, runMiddleware } from '@/utils/cors'; +import { validateUserAndToken } from '@/utils/access'; +import { putObject } from '@/utils/object'; +import { parseSubjectTag } from '@/services/send/sendAddress'; +import { + SEND_INBOX_BUCKET, + SEND_INBOX_FILE_MAX_BYTES, + SEND_INBOX_PENDING_LIMIT, +} from '@/services/constants'; + +/** + * `kind='file'` inbox endpoint for the browser extension. The extension + * builds a self-contained EPUB on the user's machine (Readability + + * inlined images + bundled stylesheet) and uploads it as the request body. + * + * Lives at its own path — `pages/api/send/inbox.ts` keeps the JSON + * bodyParser, but a binary upload needs `bodyParser: false` and a manual + * stream read. + * + * Drainer behaviour matches the email-attachment path: the payload is + * stored verbatim in the inbox R2 bucket, the row carries `kind='file'`, + * and the drainer imports the EPUB on the next open without any further + * conversion. + */ +export const config = { + api: { + bodyParser: false, + responseLimit: false, + }, +}; + +const MAX_TITLE_LENGTH = 500; +const MAX_URL_LENGTH = 2000; +const ALLOWED_MIME = new Set(['application/epub+zip', 'application/octet-stream']); + +function header(req: NextApiRequest, name: string): string | null { + const value = req.headers[name]; + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +function decodeRfc5987(value: string): string { + // `X-Readest-Title: UTF-8''Spa%C3%9F`. Used so non-ASCII titles survive + // the HTTP-header transport without arbitrary client encoding. + const m = value.match(/^UTF-8''(.+)$/i); + if (m) { + try { + return decodeURIComponent(m[1]!); + } catch { + return ''; + } + } + return value; +} + +async function readBody(req: NextApiRequest, max: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.byteLength; + if (total > max) { + reject(Object.assign(new Error('Payload too large'), { code: 'payload_too_large' })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await runMiddleware(req, res, corsAllMethods); + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { user } = await validateUserAndToken(req.headers['authorization']); + if (!user) { + return res.status(403).json({ error: 'Not authenticated' }); + } + + const contentType = header(req, 'content-type') ?? ''; + const baseType = contentType.split(';')[0]!.trim().toLowerCase(); + if (!ALLOWED_MIME.has(baseType)) { + return res.status(415).json({ error: 'Unsupported content type' }); + } + + const titleRaw = header(req, 'x-readest-title'); + const urlRaw = header(req, 'x-readest-url'); + const title = titleRaw ? decodeRfc5987(titleRaw).slice(0, MAX_TITLE_LENGTH) : null; + const sourceUrl = urlRaw ? decodeRfc5987(urlRaw).slice(0, MAX_URL_LENGTH) : null; + + if (sourceUrl && !/^https?:\/\//i.test(sourceUrl)) { + return res.status(400).json({ error: 'Invalid source URL' }); + } + + const supabase = createSupabaseAdminClient(); + + // Same anti-abuse cap as the JSON inbox endpoint: a leaked token can't + // flood R2 once the user has too many pending items. + const { count, error: countError } = await supabase + .from('send_inbox') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .in('status', ['pending', 'claimed']); + if (countError) { + return res.status(500).json({ error: countError.message }); + } + if ((count ?? 0) >= SEND_INBOX_PENDING_LIMIT) { + return res.status(429).json({ error: 'Inbox is full — open Readest to process pending items' }); + } + + let body: Buffer; + try { + body = await readBody(req, SEND_INBOX_FILE_MAX_BYTES); + } catch (err) { + if ((err as { code?: string }).code === 'payload_too_large') { + return res.status(413).json({ error: 'File is too large' }); + } + return res.status(400).json({ error: 'Could not read request body' }); + } + if (body.byteLength === 0) { + return res.status(400).json({ error: 'Empty file' }); + } + + const { data: row, error: rowError } = await supabase + .from('send_inbox') + .insert({ + user_id: user.id, + kind: 'file', + source: 'extension', + url: sourceUrl, + filename: title, + byte_size: body.byteLength, + subject_tag: parseSubjectTag(title) ?? null, + }) + .select('id') + .single<{ id: string }>(); + if (rowError) return res.status(500).json({ error: rowError.message }); + + const payloadKey = `inbox/${user.id}/${row.id}/clip.epub`; + // Allocate a fresh ArrayBuffer so we don't accidentally hand a + // SharedArrayBuffer-typed view to the S3 client, which expects an + // owned ArrayBuffer. + const payloadBuffer = new ArrayBuffer(body.byteLength); + new Uint8Array(payloadBuffer).set(body); + try { + await putObject(payloadKey, payloadBuffer, 'application/epub+zip', SEND_INBOX_BUCKET); + } catch (err) { + // Roll back the row so we never leave a `pending` item the drainer + // would only fail on. + await supabase.from('send_inbox').delete().eq('id', row.id); + console.error('Inbox file upload failed:', err); + return res.status(500).json({ error: 'Could not store EPUB' }); + } + + const { error: updateError } = await supabase + .from('send_inbox') + .update({ payload_key: payloadKey }) + .eq('id', row.id); + if (updateError) return res.status(500).json({ error: updateError.message }); + + return res.status(200).json({ id: row.id }); +} diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index fd37a5f8..2953ce2d 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -753,6 +753,12 @@ export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const; export const SEND_EMAIL_DOMAIN = 'readest.com'; export const SEND_INBOX_BUCKET = 'readest-send-inbox'; export const SEND_INBOX_PENDING_LIMIT = 50; +// Hard cap on the size of a single uploaded EPUB the browser extension can +// drop into the inbox. 30 MB is the same total-asset cap the client-side +// bundler enforces — plus a bit of head-room for chapter HTML / structural +// overhead. Beyond this size a clipped article is almost certainly an +// over-illustrated page that would never read well in the EPUB anyway. +export const SEND_INBOX_FILE_MAX_BYTES = 40 * 1024 * 1024; export const SHARE_DEFAULT_EXPIRATION_DAYS = 3; export const SHARE_MAX_PER_USER = 50; export const SHARE_TOKEN_LENGTH = 22; diff --git a/apps/readest-app/src/services/send/conversion/assetBundler.ts b/apps/readest-app/src/services/send/conversion/assetBundler.ts index c8fb90db..9f40d769 100644 --- a/apps/readest-app/src/services/send/conversion/assetBundler.ts +++ b/apps/readest-app/src/services/send/conversion/assetBundler.ts @@ -5,12 +5,18 @@ import type { EpubImage } from './types'; // On Tauri we go through the Rust HTTP client (no browser-CORS); on web // we use the native fetch — the bundler is only ever invoked from a -// CORS-free caller there (the future extension's content script). In -// Tauri we also fold in the full image-fetch header set (UA + Sec-Ch-Ua + -// Sec-Fetch-* + Referer) so CDNs that gate images on the browser shape -// — NYT, WSJ, paywalled CDNs — cooperate. +// CORS-free caller there (the browser extension's service worker, which +// has broad `host_permissions`). In the non-Tauri path we set +// `credentials: 'include'` so the browser sends the user's cookies for +// paywalled / member-only CDN hosts — without that, an authenticated +// Substack image returns a placeholder. In Tauri we also fold in the +// full image-fetch header set (UA + Sec-Ch-Ua + Sec-Fetch-* + Referer) +// so CDNs that gate images on the browser shape — NYT, WSJ, paywalled +// CDNs — cooperate. const httpFetch = (url: string, referer: string | null, init?: RequestInit): Promise => { - if (!isTauriAppPlatform()) return globalThis.fetch(url, init); + if (!isTauriAppPlatform()) { + return globalThis.fetch(url, { credentials: 'include', ...init }); + } const baseHeaders = imageFetchHeaders(referer); const headers = new Headers(init?.headers); for (const [k, v] of Object.entries(baseHeaders)) { diff --git a/biome.json b/biome.json index 8353872f..0895a9d4 100644 --- a/biome.json +++ b/biome.json @@ -61,6 +61,8 @@ "!apps/readest-app/build/**", "!apps/readest-app/public/**", "!apps/readest-app/src-tauri/**", + "!apps/readest-app/extensions/**/dist/**", + "!apps/readest-app/extensions/**/node_modules/**", "!apps/readest-app/next-env.d.ts", "!apps/readest-app/i18next-scanner.config.cjs" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d610b69f..330a8afc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,43 @@ importers: specifier: ^4.85.0 version: 4.90.0(@cloudflare/workers-types@4.20260518.1) + apps/readest-app/extensions/send-to-readest: + dependencies: + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 + '@zip.js/zip.js': + specifier: ^2.8.16 + version: 2.8.26 + dompurify: + specifier: '>=3.4.0' + version: 3.4.2 + devDependencies: + '@types/chrome': + specifier: ^0.0.270 + version: 0.0.270 + '@types/dompurify': + specifier: ^3.0.5 + version: 3.2.0 + copy-webpack-plugin: + specifier: ^14.0.0 + version: 14.0.0(webpack@5.106.2) + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + ts-loader: + specifier: ^9.5.1 + version: 9.5.7(typescript@5.9.3)(webpack@5.106.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + webpack: + specifier: ^5.97.1 + version: 5.106.2(webpack-cli@5.1.4) + webpack-cli: + specifier: ^5.1.4 + version: 5.1.4(webpack@5.106.2) + apps/readest-app/workers/send-email: dependencies: '@supabase/supabase-js': @@ -3643,6 +3680,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.0.270': + resolution: {integrity: sha512-ADvkowV7YnJfycZZxL2brluZ6STGW+9oKG37B422UePf2PCXuFA/XdERI0T18wtuWPx0tmFeZqq6MOXVk1IC+Q==} + '@types/common-tags@1.8.4': resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} @@ -3751,6 +3791,10 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3766,9 +3810,18 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -4145,6 +4198,31 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@webpack-cli/configtest@2.1.1': + resolution: {integrity: sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/info@2.0.2': + resolution: {integrity: sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/serve@2.0.5': + resolution: {integrity: sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -4697,6 +4775,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -4769,6 +4851,12 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + copy-webpack-plugin@14.0.0: + resolution: {integrity: sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==} + engines: {node: '>= 20.9.0'} + peerDependencies: + webpack: ^5.1.0 + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -5271,6 +5359,11 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + envinfo@7.21.0: + resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} + engines: {node: '>=4'} + hasBin: true + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -5479,6 +5572,10 @@ packages: resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fastparse@1.1.2: resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} @@ -5915,6 +6012,11 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -5940,6 +6042,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -7009,6 +7115,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pkginfo@0.4.1: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} @@ -7381,6 +7491,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + recursive-readdir@2.2.3: resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} engines: {node: '>=6.0.0'} @@ -7462,6 +7576,14 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-options@2.0.0: resolution: {integrity: sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==} engines: {node: '>= 10.13.0'} @@ -7495,6 +7617,11 @@ packages: rgb2hex@0.2.5: resolution: {integrity: sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -7722,6 +7849,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -8098,6 +8229,13 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-loader@9.5.7: + resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + ts-tqdm@0.8.6: resolution: {integrity: sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ==} @@ -8502,6 +8640,27 @@ packages: engines: {node: '>= 10.13.0'} hasBin: true + webpack-cli@5.1.4: + resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} + engines: {node: '>=14.15.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + webpack: 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + webpack-sources@3.4.1: resolution: {integrity: sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==} engines: {node: '>=10.13.0'} @@ -8565,6 +8724,9 @@ packages: engines: {node: '>=8'} hasBin: true + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + workerd@1.20260507.1: resolution: {integrity: sha512-z7JhsFSe6+X1b5fUHaVpo15VM1IRMJiLofEkq8iKdCo+Veqc+FUg5lIsuz8NwePxuSKrXtO4ZQpGkQLbPVXFhg==} engines: {node: '>=16'} @@ -11985,6 +12147,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.0.270': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/common-tags@1.8.4': {} '@types/cors@2.8.19': @@ -12116,6 +12283,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.4.2 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -12134,8 +12305,16 @@ snapshots: '@types/estree@1.0.9': {} + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + '@types/geojson@7946.0.16': {} + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -12694,6 +12873,21 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.106.2)': + dependencies: + webpack: 5.106.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.106.2) + + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.106.2)': + dependencies: + webpack: 5.106.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.106.2) + + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.106.2)': + dependencies: + webpack: 5.106.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.106.2) + '@xmldom/xmldom@0.8.13': {} '@xtuc/ieee754@1.2.0': {} @@ -13246,6 +13440,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -13293,6 +13489,15 @@ snapshots: cookie@1.1.1: {} + copy-webpack-plugin@14.0.0(webpack@5.106.2): + dependencies: + glob-parent: 6.0.2 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 7.0.5 + tinyglobby: 0.2.16 + webpack: 5.106.2(webpack-cli@5.1.4) + core-js@3.49.0: {} core-util-is@1.0.3: {} @@ -13820,6 +14025,8 @@ snapshots: entities@8.0.0: {} + envinfo@7.21.0: {} + environment@1.1.0: {} eol@0.9.1: {} @@ -14131,6 +14338,8 @@ snapshots: strnum: 2.3.0 xml-naming: 0.1.0 + fastest-levenshtein@1.0.16: {} + fastparse@1.1.2: {} fastq@1.20.1: @@ -14702,6 +14911,11 @@ snapshots: immediate@3.0.6: {} + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} inherits@2.0.4: {} @@ -14724,6 +14938,8 @@ snapshots: internmap@2.0.3: {} + interpret@3.1.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -15999,6 +16215,10 @@ snapshots: pirates@4.0.7: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pkginfo@0.4.1: {} playwright-core@1.60.0: {} @@ -16416,6 +16636,10 @@ snapshots: readdirp@4.1.2: {} + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + recursive-readdir@2.2.3: dependencies: minimatch: 3.1.5 @@ -16530,6 +16754,12 @@ snapshots: require-main-filename@2.0.0: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + resolve-options@2.0.0: dependencies: value-or-function: 4.0.0 @@ -16560,6 +16790,11 @@ snapshots: rgb2hex@0.2.5: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + robust-predicates@3.0.3: {} rollup@4.60.3: @@ -16871,6 +17106,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 @@ -17153,6 +17390,14 @@ snapshots: optionalDependencies: postcss: 8.5.14 + terser-webpack-plugin@5.6.0(webpack@5.106.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.47.1 + webpack: 5.106.2(webpack-cli@5.1.4) + terser@5.16.9: dependencies: '@jridgewell/source-map': 0.3.11 @@ -17259,6 +17504,16 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-loader@9.5.7(typescript@5.9.3)(webpack@5.106.2): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.21.3 + micromatch: 4.0.8 + semver: 7.8.0 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.106.2(webpack-cli@5.1.4) + ts-tqdm@0.8.6: {} tsconfck@3.1.6(typescript@5.9.3): @@ -17714,6 +17969,29 @@ snapshots: - bufferutil - utf-8-validate + webpack-cli@5.1.4(webpack@5.106.2): + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.106.2) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.106.2) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.106.2) + colorette: 2.0.20 + commander: 10.0.1 + cross-spawn: 7.0.6 + envinfo: 7.21.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.106.2(webpack-cli@5.1.4) + webpack-merge: 5.10.0 + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + webpack-sources@3.4.1: {} webpack@5.106.2(postcss@8.5.14): @@ -17756,6 +18034,48 @@ snapshots: - postcss - uglify-js + webpack@5.106.2(webpack-cli@5.1.4): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.21.3 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.0(webpack@5.106.2) + watchpack: 2.5.1 + webpack-sources: 3.4.1 + optionalDependencies: + webpack-cli: 5.1.4(webpack@5.106.2) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -17804,6 +18124,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wildcard@2.0.1: {} + workerd@1.20260507.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260507.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 503014f9..0f0b4398 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - apps/* - apps/readest-app/workers/send-email + - apps/readest-app/extensions/* - packages/foliate-js allowBuilds: