From f6dfd09d82ea360a41721a175412e2d079dff8e6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 22 May 2026 20:45:29 +0800 Subject: [PATCH] feat(send): browser extension that clips pages into Readest as EPUBs (#4266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the URL-only placeholder extension with a full MV3 page-clipper that builds a self-contained EPUB on the user's machine and uploads it to the inbox. - Captures the rendered DOM in a content script, then runs Readability, asset bundling, and EPUB build through the shared `convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen document (the SW lacks DOMParser). - Uploads the resulting EPUB directly from the offscreen page to the new `POST /api/send/inbox/file` endpoint — keeps the bytes in one realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to `{}` between extension contexts. - Adds a long-lived Port + ping handshake between SW, offscreen, and the on-demand capture content script so neither idle-eviction nor load-order races can hang the popup. - Localised popup, badge feedback, key-as-content i18n (`_('English source')`) with an extract script that seeds locale stubs from i18n-langs.json and writes a static-imports map for the runtime. All 33 locales fully translated. - Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body (Content-Type: application/epub+zip), enforces the inbox pending cap, stores to the existing send-inbox R2 bucket as `kind='file'`. `assetBundler` now sets `credentials: 'include'` in the non-Tauri branch so the extension SW carries paywalled-CDN cookies. - 47 vitest cases for the extension shell (upload, badge, auth, lazy, popup state machine, auth-bridge token sync) + 8 cases for the new server endpoint. CI's `test_web_app` invokes both via the extended `test:pr:web` plus a `build-browser-ext` step that catches webpack alias / Tauri-stub regressions. Co-authored-by: Claude Opus 4.7 (1M context) --- .../extension/send-to-readest/README.md | 28 -- .../extension/send-to-readest/content.js | 24 -- .../extension/send-to-readest/manifest.json | 19 -- .../extension/send-to-readest/popup.html | 64 ---- .../extension/send-to-readest/popup.js | 74 ---- .../extensions/send-to-readest/.gitignore | 15 + .../extensions/send-to-readest/README.md | 233 +++++++++++++ .../send-to-readest/_locales/ar/messages.json | 14 + .../send-to-readest/_locales/bn/messages.json | 14 + .../send-to-readest/_locales/bo/messages.json | 14 + .../send-to-readest/_locales/de/messages.json | 14 + .../send-to-readest/_locales/el/messages.json | 14 + .../send-to-readest/_locales/en/messages.json | 14 + .../send-to-readest/_locales/es/messages.json | 14 + .../send-to-readest/_locales/fa/messages.json | 14 + .../send-to-readest/_locales/fr/messages.json | 14 + .../send-to-readest/_locales/he/messages.json | 14 + .../send-to-readest/_locales/hi/messages.json | 14 + .../send-to-readest/_locales/hu/messages.json | 14 + .../send-to-readest/_locales/id/messages.json | 14 + .../send-to-readest/_locales/it/messages.json | 14 + .../send-to-readest/_locales/ja/messages.json | 14 + .../send-to-readest/_locales/ko/messages.json | 14 + .../send-to-readest/_locales/ms/messages.json | 14 + .../send-to-readest/_locales/nl/messages.json | 14 + .../send-to-readest/_locales/pl/messages.json | 14 + .../_locales/pt-BR/messages.json | 14 + .../send-to-readest/_locales/pt/messages.json | 14 + .../send-to-readest/_locales/ro/messages.json | 14 + .../send-to-readest/_locales/ru/messages.json | 14 + .../send-to-readest/_locales/si/messages.json | 14 + .../send-to-readest/_locales/sl/messages.json | 14 + .../send-to-readest/_locales/sv/messages.json | 14 + .../send-to-readest/_locales/ta/messages.json | 14 + .../send-to-readest/_locales/th/messages.json | 14 + .../send-to-readest/_locales/tr/messages.json | 14 + .../send-to-readest/_locales/uk/messages.json | 14 + .../send-to-readest/_locales/uz/messages.json | 14 + .../send-to-readest/_locales/vi/messages.json | 14 + .../_locales/zh-CN/messages.json | 14 + .../_locales/zh-TW/messages.json | 14 + .../send-to-readest/icons/icon-128.png | Bin 0 -> 22708 bytes .../send-to-readest/icons/icon-16.png | Bin 0 -> 2040 bytes .../send-to-readest/icons/icon-256.png | Bin 0 -> 75372 bytes .../send-to-readest/icons/icon-32.png | Bin 0 -> 3187 bytes .../send-to-readest/icons/icon-48.png | Bin 0 -> 4979 bytes .../extensions/send-to-readest/manifest.json | 41 +++ .../extensions/send-to-readest/offscreen.html | 14 + .../extensions/send-to-readest/package.json | 28 ++ .../extensions/send-to-readest/popup.html | 205 +++++++++++ .../send-to-readest/scripts/extract-i18n.mjs | 236 +++++++++++++ .../src/__test-utils__/chromeMock.ts | 111 ++++++ .../src/background/badge.test.ts | 60 ++++ .../send-to-readest/src/background/badge.ts | 34 ++ .../src/background/offscreen.ts | 192 +++++++++++ .../src/background/service-worker.ts | 235 +++++++++++++ .../src/background/upload.test.ts | 178 ++++++++++ .../send-to-readest/src/background/upload.ts | 126 +++++++ .../src/content/auth-bridge.test.ts | 135 ++++++++ .../src/content/auth-bridge.ts | 56 +++ .../send-to-readest/src/content/capture.ts | 67 ++++ .../send-to-readest/src/lib/auth.test.ts | 77 +++++ .../send-to-readest/src/lib/auth.ts | 36 ++ .../send-to-readest/src/lib/i18n.ts | 91 +++++ .../send-to-readest/src/lib/messages.ts | 64 ++++ .../send-to-readest/src/locales/ar.json | 31 ++ .../send-to-readest/src/locales/bn.json | 31 ++ .../send-to-readest/src/locales/bo.json | 31 ++ .../send-to-readest/src/locales/de.json | 31 ++ .../send-to-readest/src/locales/el.json | 31 ++ .../send-to-readest/src/locales/en.json | 1 + .../send-to-readest/src/locales/es.json | 31 ++ .../send-to-readest/src/locales/fa.json | 31 ++ .../send-to-readest/src/locales/fr.json | 31 ++ .../send-to-readest/src/locales/he.json | 31 ++ .../send-to-readest/src/locales/hi.json | 31 ++ .../send-to-readest/src/locales/hu.json | 31 ++ .../send-to-readest/src/locales/id.json | 31 ++ .../send-to-readest/src/locales/index.ts | 78 +++++ .../send-to-readest/src/locales/it.json | 31 ++ .../send-to-readest/src/locales/ja.json | 31 ++ .../send-to-readest/src/locales/ko.json | 31 ++ .../send-to-readest/src/locales/ms.json | 31 ++ .../send-to-readest/src/locales/nl.json | 31 ++ .../send-to-readest/src/locales/pl.json | 31 ++ .../send-to-readest/src/locales/pt-BR.json | 31 ++ .../send-to-readest/src/locales/pt.json | 31 ++ .../send-to-readest/src/locales/ro.json | 31 ++ .../send-to-readest/src/locales/ru.json | 31 ++ .../send-to-readest/src/locales/si.json | 31 ++ .../send-to-readest/src/locales/sl.json | 31 ++ .../send-to-readest/src/locales/sv.json | 31 ++ .../send-to-readest/src/locales/ta.json | 31 ++ .../send-to-readest/src/locales/th.json | 31 ++ .../send-to-readest/src/locales/tr.json | 31 ++ .../send-to-readest/src/locales/uk.json | 31 ++ .../send-to-readest/src/locales/uz.json | 31 ++ .../send-to-readest/src/locales/vi.json | 31 ++ .../send-to-readest/src/locales/zh-CN.json | 31 ++ .../send-to-readest/src/locales/zh-TW.json | 31 ++ .../src/offscreen/offscreen.ts | 110 ++++++ .../send-to-readest/src/popup/popup.test.ts | 195 +++++++++++ .../send-to-readest/src/popup/popup.ts | 168 +++++++++ .../send-to-readest/src/stubs/environment.ts | 13 + .../send-to-readest/src/stubs/tauri-http.ts | 14 + .../extensions/send-to-readest/tsconfig.json | 30 ++ .../send-to-readest/webpack.config.js | 103 ++++++ apps/readest-app/package.json | 5 +- .../services/send-inbox-file-api.test.ts | 226 ++++++++++++ .../src/pages/api/send/inbox/file.ts | 170 +++++++++ apps/readest-app/src/services/constants.ts | 6 + .../services/send/conversion/assetBundler.ts | 16 +- biome.json | 2 + pnpm-lock.yaml | 322 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 115 files changed, 5187 insertions(+), 215 deletions(-) delete mode 100644 apps/readest-app/extension/send-to-readest/README.md delete mode 100644 apps/readest-app/extension/send-to-readest/content.js delete mode 100644 apps/readest-app/extension/send-to-readest/manifest.json delete mode 100644 apps/readest-app/extension/send-to-readest/popup.html delete mode 100644 apps/readest-app/extension/send-to-readest/popup.js create mode 100644 apps/readest-app/extensions/send-to-readest/.gitignore create mode 100644 apps/readest-app/extensions/send-to-readest/README.md create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ar/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/bn/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/bo/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/de/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/el/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/en/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/es/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/fa/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/fr/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/he/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/hi/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/hu/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/id/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/it/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ja/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ko/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ms/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/nl/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/pl/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/pt-BR/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/pt/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ro/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ru/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/si/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/sl/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/sv/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/ta/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/th/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/tr/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/uk/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/uz/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/vi/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/zh-CN/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/_locales/zh-TW/messages.json create mode 100644 apps/readest-app/extensions/send-to-readest/icons/icon-128.png create mode 100644 apps/readest-app/extensions/send-to-readest/icons/icon-16.png create mode 100644 apps/readest-app/extensions/send-to-readest/icons/icon-256.png create mode 100644 apps/readest-app/extensions/send-to-readest/icons/icon-32.png create mode 100644 apps/readest-app/extensions/send-to-readest/icons/icon-48.png create mode 100644 apps/readest-app/extensions/send-to-readest/manifest.json create mode 100644 apps/readest-app/extensions/send-to-readest/offscreen.html create mode 100644 apps/readest-app/extensions/send-to-readest/package.json create mode 100644 apps/readest-app/extensions/send-to-readest/popup.html create mode 100644 apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs create mode 100644 apps/readest-app/extensions/send-to-readest/src/__test-utils__/chromeMock.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/badge.test.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/badge.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/offscreen.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/service-worker.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/upload.test.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/background/upload.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.test.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/content/auth-bridge.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/content/capture.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/lib/auth.test.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/lib/auth.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/lib/messages.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ar.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/bn.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/bo.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/de.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/el.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/en.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/es.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/fa.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/fr.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/he.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/hi.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/hu.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/id.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/index.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/it.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ja.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ko.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ms.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/nl.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/pl.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/pt-BR.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/pt.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ro.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ru.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/si.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/sl.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/sv.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/ta.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/th.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/tr.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/uk.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/uz.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/vi.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/zh-CN.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/locales/zh-TW.json create mode 100644 apps/readest-app/extensions/send-to-readest/src/offscreen/offscreen.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/popup/popup.test.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/popup/popup.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/stubs/environment.ts create mode 100644 apps/readest-app/extensions/send-to-readest/src/stubs/tauri-http.ts create mode 100644 apps/readest-app/extensions/send-to-readest/tsconfig.json create mode 100644 apps/readest-app/extensions/send-to-readest/webpack.config.js create mode 100644 apps/readest-app/src/__tests__/services/send-inbox-file-api.test.ts create mode 100644 apps/readest-app/src/pages/api/send/inbox/file.ts 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 0000000000000000000000000000000000000000..f90ae3bf505abac3f130fff1204fabc89749e4ee GIT binary patch literal 22708 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6e75a2e44e878cd61ba1a3917473e36697b5281c GIT binary patch literal 2040 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4(FKU|gH&>>S|f?5q$_l%JNFlghxL zF|l@{zeZ}I$Z>l)eIZS?$cUI$(a9H5PHvu{w7xy**rD3*e}muO{r-BNx~BKiH6p5J z|8#TKZdj6{Yq=ic9W&tKENEa>EQ}@Kjvcd?UV8n<%B8cyqcos=O-jx?ZU^~=RRmdBOb(s(y~W;Q)07XL&vqQQH*L55H+i)r=hr6x z^CoLrBn7V-N3Us_EOf2f+$~6Ot#*IzS?yVeS@(Wq@>5;D_PFg=`7oEa7ZYRkBmL$7 z@(3MKIAap5aV8hUc&l6jjWo6H=ug(MyQFl- zZ}t=Qzh?Tj=d)k>zWBv{)Vor54;MRkz<2HSrg;umZQjh+xNKM)v~h{PSo7@I%1MPQ zesVpHH`>@&mjAnp;Ya=Mce%n-^bY@>^!i@z&Ub&enZzHO`1a=?i|)DH5;L0{izA}j zpY6C~`LRtt)zbdn_cd!Sd}jCdZ<44A`qK@JzfGwTo@u_m3|c@o2Ln5!5Cb!i#R!B9 zj8Y7&U^W9o5~DPnoyDjDRRdJXXvn|>RKp9zMT|yZwg`~j(4NV_0@XVONQ0Obs{~eI1!RMS^_3LBN=mYAl_G#nbj~kIRWQ>t&@)i7<5EyiuqjGOvkG!? zgK7uzY?U%fN(!v>^~=l4^~#O)@{7{-4J|D#^$m>ljf`}GDs+o0^GXscbn}XpA%?)r zaY-#sF3Kz@$;{7F0GXSZlwVq6tE2?72o50bEXhnm*pycc^%l^B`XCv7Lp=k1Y}!&Q zGI3}F$>7um3?sO=P%NuREP!YN2L{BAHXs*RMFyo7=jW7y{9#stKTP@9m&kko^M$I3ZBC%-7T zATc@B4(JzjAtcqn@UrsFPsvQHbV)2pwF4z*T?0d1L(33Ya<OCx*b=)|Drp<93|=L;uvBfcyyv| z{^bCXw)?*y-YVQ6t)R;*?UdvcDS3#2+v5$ZS=o^j@d>P*pSWKHO);&An&<6|42Szue{#b|=itUf=Y4?9LW9!9p?cZ|po8RZ>Z(Ke< zhk3>RIkra4uWUD~zBw&Aj)~p@_~+xpaj3DcLT4wlSHUvIBVq7h0J@F>bsP?UEbX&+;pc> zYGRl4MInJNiYeNx#+DZ)*DTai`EX^$OO97^dnQ}Nc-NlLYd(?qL!sbUfOwS4j9#7F zKlc<~epRL^uEO_K=;9lP$=d#3d$>DH@>|?AD>li!iheEaaIMjQ{Q-rAPQ@3$-4|-+ z-?m~xF#Gb%uv<3D=1RHEH+c>}&R?;A-h*$488dZF{stXBDd+llfkswsQ9;NDkErZJ zYZaG1xZL^5g6Zh;{by>7L__EJTNX1N>soceib3ks>$?4GzpPo^H8V^&_4Fo}TW`Bo zD6@MR|NawqEn0BR_p2b;L4;y$UobKIUll%Xuq|@^dP$B8*>gTe~ HDWM4fC8h5{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..996eabad7458026f1964d6a7ee88cdf21dba4d06 GIT binary patch literal 75372 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

8JvVh!_*BFC6LV?e4Z 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 bQPcKoC{b(E;?HLM?=u@K2g`DEpSb@4_GKKy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4e80f2c091578b2d8694c3d636af3234f67edbc GIT binary patch literal 4979 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"], + "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: