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 similarity index 100% rename from apps/readest-app/extensions/send-to-readest/_locales/pt-BR/messages.json rename to apps/readest-app/extensions/send-to-readest/_locales/pt_BR/messages.json 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 similarity index 100% rename from apps/readest-app/extensions/send-to-readest/_locales/zh-CN/messages.json rename to apps/readest-app/extensions/send-to-readest/_locales/zh_CN/messages.json 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 similarity index 100% rename from apps/readest-app/extensions/send-to-readest/_locales/zh-TW/messages.json rename to apps/readest-app/extensions/send-to-readest/_locales/zh_TW/messages.json 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 index 21053f15..59b8203c 100644 --- a/apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs +++ b/apps/readest-app/extensions/send-to-readest/scripts/extract-i18n.mjs @@ -116,6 +116,13 @@ function localeFiles() { return codes.map((code) => ({ code, path: join(LOCALES_DIR, `${code}.json`) })); } +function chromeLocaleCode(code) { + // Chrome's native i18n directories use underscore region separators + // (`pt_BR`, `zh_CN`, `zh_TW`) while the app locale list uses BCP-47 + // (`pt-BR`, `zh-CN`, `zh-TW`). + return code.replace(/-/g, '_'); +} + /** * Mirror the locale list into Chrome's native `_locales//messages.json` * so each locale carries the manifest-side translations (extension name, @@ -132,7 +139,7 @@ function seedChromeLocales() { const codes = loadLangs(); for (const code of codes) { if (code === 'en') continue; - const path = join(CHROME_LOCALES_DIR, code, 'messages.json'); + const path = join(CHROME_LOCALES_DIR, chromeLocaleCode(code), 'messages.json'); if (existsSync(path)) continue; // never overwrite a translated bundle const stub = {}; for (const [key, entry] of Object.entries(enBundle)) { @@ -152,7 +159,7 @@ function seedChromeLocales() { 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 mapEntries = sorted.map((c) => ` ${jsObjectKey(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 @@ -175,6 +182,10 @@ function jsIdent(code) { return code.replace(/[-_](\w)/g, (_m, c) => c.toUpperCase()); } +function jsObjectKey(code) { + return /^[A-Za-z_$][\w$]*$/.test(code) ? code : `'${code}'`; +} + function main() { const check = process.argv.includes('--check'); const keys = extract(); 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 index ef9a9fb7..dede6017 100644 --- 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 @@ -41,6 +41,9 @@ export interface ChromeMock { onConnect: { addListener: Mock }; lastError: chrome.runtime.LastError | undefined; }; + i18n: { + getUILanguage: Mock; + }; } /** Reset all spies and seed a fresh `storage.local` and `storage.session`. */ @@ -100,6 +103,9 @@ export function installChromeMock(): ChromeMock { onConnect: { addListener: vi.fn() }, lastError: undefined, }, + i18n: { + getUILanguage: vi.fn(() => 'en'), + }, }; (globalThis as unknown as { chrome: ChromeMock }).chrome = chromeMock; 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 index 930a5e9c..32106444 100644 --- 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 @@ -16,6 +16,7 @@ let chromeMock: ChromeMock; let addEventListenerSpy: ReturnType; beforeEach(() => { + vi.useFakeTimers(); chromeMock = installChromeMock(); localStorage.clear(); addEventListenerSpy = vi.spyOn(window, 'addEventListener'); @@ -23,6 +24,8 @@ beforeEach(() => { }); afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); addEventListenerSpy.mockRestore(); uninstallChromeMock(); localStorage.clear(); @@ -101,6 +104,25 @@ describe('auth-bridge — token extraction', () => { expect(calls.length).toBe(1); }); + test('polls for same-page token writes that do not fire a storage event', async () => { + await import('./auth-bridge'); + chromeMock.storage.local.set.mockClear(); + chromeMock.storage.local.remove.mockClear(); + + localStorage.setItem( + 'sb-projectref-auth-token', + JSON.stringify({ access_token: 'same-page-token' }), + ); + await vi.advanceTimersByTimeAsync(5_000); + + expect(chromeMock.storage.local.set).toHaveBeenCalledTimes(1); + expect( + (chromeMock.storage.local.set.mock.calls[0]![0] as Record)[ + 'readestAccessToken' + ], + ).toBe('same-page-token'); + }); + test('storage event with a non-sb key is ignored', async () => { await import('./auth-bridge'); chromeMock.storage.local.set.mockClear(); 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 index 79ce1614..5b94ac19 100644 --- 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 @@ -12,6 +12,9 @@ interface SupabaseAuthValue { access_token?: string; } +const TOKEN_SYNC_INTERVAL_MS = 5_000; +let lastSyncedToken: string | null | undefined; + function findAccessToken(): string | null { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -32,6 +35,9 @@ function findAccessToken(): string | null { function syncToken(): void { const token = findAccessToken(); + if (token === lastSyncedToken) return; + lastSyncedToken = token; + if (token) { chrome.storage.local.set({ readestAccessToken: token, @@ -46,11 +52,19 @@ function syncToken(): void { 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. +// Refresh periodically because the browser `storage` event only fires in +// other same-origin documents, not in the SPA document that performed the +// Supabase localStorage write. Deduping above keeps this cheap and avoids +// rewriting extension storage when the token has not changed. +setInterval(syncToken, TOKEN_SYNC_INTERVAL_MS); + +// Still listen for cross-tab updates so a second Readest tab can refresh this +// content script without waiting for the next poll. window.addEventListener('storage', (event) => { if (event.key && /^sb-.*-auth-token$/.test(event.key)) { syncToken(); } }); + +window.addEventListener('focus', syncToken); +window.addEventListener('pageshow', syncToken); diff --git a/apps/readest-app/extensions/send-to-readest/src/lib/i18n.test.ts b/apps/readest-app/extensions/send-to-readest/src/lib/i18n.test.ts new file mode 100644 index 00000000..db8a9c06 --- /dev/null +++ b/apps/readest-app/extensions/send-to-readest/src/lib/i18n.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + installChromeMock, + uninstallChromeMock, + type ChromeMock, +} from '../__test-utils__/chromeMock'; + +let chromeMock: ChromeMock; + +beforeEach(() => { + chromeMock = installChromeMock(); + vi.resetModules(); +}); + +afterEach(() => { + uninstallChromeMock(); +}); + +describe('extension i18n locale selection', () => { + test('uses exact regional bundles for Chrome hyphen locale codes', async () => { + chromeMock.i18n.getUILanguage.mockReturnValue('zh-CN'); + const { translate } = await import('./i18n'); + expect(translate('Send to Readest')).toBe('发送到 Readest'); + }); + + test('uses exact regional bundles for Chrome underscore locale codes', async () => { + chromeMock.i18n.getUILanguage.mockReturnValue('pt_BR'); + const { translate } = await import('./i18n'); + expect(translate('Article is too large to send')).toBe( + 'O artigo é grande demais para ser enviado', + ); + }); + + test('falls back to the base language when no regional bundle exists', async () => { + chromeMock.i18n.getUILanguage.mockReturnValue('de-CH'); + const { translate } = await import('./i18n'); + expect(translate('Send to Readest')).toBe('An Readest senden'); + }); +}); 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 index d414f269..551dfc13 100644 --- a/apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts +++ b/apps/readest-app/extensions/send-to-readest/src/lib/i18n.ts @@ -8,8 +8,7 @@ * _('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 + * Translation tables live at `src/locales/.json`. The English bundle is * literally `{}` — fall-through to the key. Non-English bundles are * `{ "": "" }`. * @@ -45,6 +44,20 @@ function loadBundle(raw: unknown): Messages { const bundles: Record = Object.fromEntries( Object.entries(rawBundles).map(([code, raw]) => [code, loadBundle(raw)]), ); +const bundleCodeByLower = new Map(Object.keys(bundles).map((code) => [code.toLowerCase(), code])); + +function resolveBundleCode(uiLocale: string): string { + const normalized = uiLocale.replace(/_/g, '-'); + const exact = bundleCodeByLower.get(normalized.toLowerCase()); + if (exact) return exact; + + const language = normalized.split('-')[0]?.toLowerCase(); + if (language) { + const base = bundleCodeByLower.get(language); + if (base) return base; + } + return 'en'; +} function pickLocale(): string { // `chrome` is undeclared (not just undefined) outside extension @@ -52,8 +65,7 @@ function pickLocale(): string { // (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'; + return resolveBundleCode(ui); } const active: Messages = bundles[pickLocale()] ?? bundles['en'] ?? {}; diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index a0614179..7eb961aa 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -23,11 +23,11 @@ "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:extension": "dotenv -e .env -e .env.test.local -- vitest 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 && pnpm test:extension", + "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser && pnpm test:extension && pnpm build-browser-ext", "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 index 20d43df7..98db41ec 100644 --- 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 @@ -17,8 +17,10 @@ vi.mock('@/utils/cors', () => ({ })); const putObjectMock = vi.fn(); +const deleteObjectMock = vi.fn(); vi.mock('@/utils/object', () => ({ putObject: (...args: unknown[]) => putObjectMock(...args), + deleteObject: (...args: unknown[]) => deleteObjectMock(...args), })); const insertMock = vi.fn(); @@ -115,6 +117,7 @@ const VALID_HEADERS = { beforeEach(() => { validateUserMock.mockReset().mockResolvedValue({ user: validUser }); putObjectMock.mockReset().mockResolvedValue(undefined); + deleteObjectMock.mockReset().mockResolvedValue(undefined); countMock.mockReset().mockResolvedValue({ count: 0, error: null }); insertMock.mockReset().mockResolvedValue({ data: { id: 'inbox-1' }, error: null }); updateMock.mockReset().mockResolvedValue({ error: null }); @@ -222,5 +225,20 @@ describe('POST /api/send/inbox/file', () => { expect(res._status).toBe(500); expect(deleteMock).toHaveBeenCalledTimes(1); + expect(deleteObjectMock).not.toHaveBeenCalled(); + }); + + test('rolls back the inbox row and stored payload when payload_key update fails', async () => { + updateMock.mockResolvedValueOnce({ error: { message: 'update failed' } }); + 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); + expect(deleteObjectMock).toHaveBeenCalledWith( + 'inbox/user-123/inbox-1/clip.epub', + expect.any(String), + ); }); }); diff --git a/apps/readest-app/src/pages/api/send/inbox/file.ts b/apps/readest-app/src/pages/api/send/inbox/file.ts index ff1fa5e1..0e03e795 100644 --- a/apps/readest-app/src/pages/api/send/inbox/file.ts +++ b/apps/readest-app/src/pages/api/send/inbox/file.ts @@ -2,7 +2,7 @@ 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 { deleteObject, putObject } from '@/utils/object'; import { parseSubjectTag } from '@/services/send/sendAddress'; import { SEND_INBOX_BUCKET, @@ -164,7 +164,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) .from('send_inbox') .update({ payload_key: payloadKey }) .eq('id', row.id); - if (updateError) return res.status(500).json({ error: updateError.message }); + if (updateError) { + await supabase.from('send_inbox').delete().eq('id', row.id); + try { + await deleteObject(payloadKey, SEND_INBOX_BUCKET); + } catch (err) { + console.warn('Inbox file payload cleanup failed:', err); + } + return res.status(500).json({ error: updateError.message }); + } return res.status(200).json({ id: row.id }); } diff --git a/apps/readest-app/src/services/send/conversion/assetBundler.ts b/apps/readest-app/src/services/send/conversion/assetBundler.ts index 9f40d769..c85c5d54 100644 --- a/apps/readest-app/src/services/send/conversion/assetBundler.ts +++ b/apps/readest-app/src/services/send/conversion/assetBundler.ts @@ -154,7 +154,7 @@ function hex(bytes: Uint8Array, max = 16): string { } async function sha256(bytes: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest('SHA-256', bytes); + const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)); return new Uint8Array(digest); } diff --git a/apps/readest-app/src/services/send/conversion/convertToEpub.ts b/apps/readest-app/src/services/send/conversion/convertToEpub.ts index 19d8b10a..38054ca2 100644 --- a/apps/readest-app/src/services/send/conversion/convertToEpub.ts +++ b/apps/readest-app/src/services/send/conversion/convertToEpub.ts @@ -149,7 +149,8 @@ async function htmlToBook( images, coverImage, ); - const file = new File([blob], `${safeFileName(title)}.epub`, { + const fileBytes = new Uint8Array(await blob.arrayBuffer()); + const file = new File([fileBytes], `${safeFileName(title)}.epub`, { type: 'application/epub+zip', }); return { file, title, author };