From 298d4872a04bb865d2af09d3d39df8c53c7560a1 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 6 Apr 2026 01:48:25 +0800 Subject: [PATCH] fix(translate): disable yandex provider while upstream relay is down (#3765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The translate.toil.cc relay that backs the yandex provider is currently unavailable. Introduce a `disabled` flag on TranslationProvider and filter disabled providers out of both `getTranslators()` and `getTranslator()` so the settings panel, translator popup, and the fallback logic in `useTranslator` all agree the provider doesn't exist — no per-callsite changes needed. Flip the flag back (or delete the line) on `yandexProvider` to re-enable once the upstream is healthy. Co-authored-by: Claude Opus 4.6 (1M context) --- .../services/translators/providers.test.ts | 84 +++++++++++++++++++ .../components/annotator/TranslatorPopup.tsx | 32 +++---- apps/readest-app/src/components/Select.tsx | 6 +- .../src/components/settings/LangPanel.tsx | 28 +++---- apps/readest-app/src/hooks/useTranslator.ts | 12 ++- .../services/translators/providers/index.ts | 42 ++++++++++ .../services/translators/providers/yandex.ts | 4 + .../src/services/translators/types.ts | 8 ++ 8 files changed, 181 insertions(+), 35 deletions(-) diff --git a/apps/readest-app/src/__tests__/services/translators/providers.test.ts b/apps/readest-app/src/__tests__/services/translators/providers.test.ts index 2a66f79f..b8b909d0 100644 --- a/apps/readest-app/src/__tests__/services/translators/providers.test.ts +++ b/apps/readest-app/src/__tests__/services/translators/providers.test.ts @@ -42,6 +42,18 @@ vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn(), })); +// Stub Supabase so importing the full providers registry (which pulls in +// deepl.ts → @/utils/access → @/utils/supabase) doesn't instantiate a real +// GoTrueClient on every `vi.resetModules()` round. Without this, each test +// that dynamically imports the registry logs a "Multiple GoTrueClient +// instances" warning from the real Supabase client. +vi.mock('@/utils/supabase', () => ({ + supabase: { + auth: { getSession: vi.fn().mockResolvedValue({ data: { session: null } }) }, + from: vi.fn(), + }, +})); + const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -316,3 +328,75 @@ describe('azureProvider', () => { expect(azureProvider.label).toBe('Azure Translator'); }); }); + +// --------------------------------------------------------------------------- +// Provider registry — disabled providers stay visible but unselectable +// --------------------------------------------------------------------------- +describe('provider registry disabled handling', () => { + // No `vi.resetModules()` here — these tests only inspect static provider + // metadata, so resolving the registry once is enough. Resetting between + // each test would re-evaluate the full import chain and churn module + // state for no benefit. + + it('keeps yandex in getTranslators() so the UI can render it', async () => { + const { getTranslators } = await import('@/services/translators/providers'); + const names = getTranslators().map((t) => t.name); + expect(names).toContain('yandex'); + }); + + it('exposes yandex as disabled so callers can grey it out', async () => { + const { getTranslator } = await import('@/services/translators/providers'); + const yandex = getTranslator('yandex'); + expect(yandex).toBeDefined(); + expect(yandex!.disabled).toBe(true); + }); + + it('isTranslatorAvailable returns false for disabled providers', async () => { + const { getTranslator, isTranslatorAvailable } = + await import('@/services/translators/providers'); + const yandex = getTranslator('yandex')!; + expect(isTranslatorAvailable(yandex, true)).toBe(false); + expect(isTranslatorAvailable(yandex, false)).toBe(false); + }); + + it('isTranslatorAvailable returns false for authRequired without token', async () => { + const { isTranslatorAvailable } = await import('@/services/translators/providers'); + const authed = { name: 'x', label: 'X', authRequired: true, translate: async () => [] }; + expect(isTranslatorAvailable(authed, false)).toBe(false); + expect(isTranslatorAvailable(authed, true)).toBe(true); + }); + + it('isTranslatorAvailable returns false when quota is exceeded', async () => { + const { isTranslatorAvailable } = await import('@/services/translators/providers'); + const exhausted = { name: 'x', label: 'X', quotaExceeded: true, translate: async () => [] }; + expect(isTranslatorAvailable(exhausted, true)).toBe(false); + }); + + it('getTranslatorDisplayLabel appends a Unavailable suffix for disabled providers', async () => { + const { getTranslator, getTranslatorDisplayLabel } = + await import('@/services/translators/providers'); + const yandex = getTranslator('yandex')!; + const label = getTranslatorDisplayLabel(yandex, true, (s) => s); + expect(label).toBe('Yandex Translate (Unavailable)'); + }); + + it('getTranslatorDisplayLabel prefers the disabled suffix over other statuses', async () => { + const { getTranslatorDisplayLabel } = await import('@/services/translators/providers'); + const both = { + name: 'x', + label: 'X', + disabled: true, + authRequired: true, + quotaExceeded: true, + translate: async () => [], + }; + expect(getTranslatorDisplayLabel(both, false, (s) => s)).toBe('X (Unavailable)'); + }); + + it('getTranslatorDisplayLabel returns the plain label for healthy providers', async () => { + const { getTranslator, getTranslatorDisplayLabel } = + await import('@/services/translators/providers'); + const google = getTranslator('google')!; + expect(getTranslatorDisplayLabel(google, true, (s) => s)).toBe('Google Translate'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx index a7934ff4..dabb4d0f 100644 --- a/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx @@ -6,7 +6,12 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useTranslator } from '@/hooks/useTranslator'; import { TRANSLATOR_LANGS } from '@/services/constants'; -import { UseTranslatorOptions, getTranslators } from '@/services/translators'; +import { + UseTranslatorOptions, + getTranslatorDisplayLabel, + getTranslators, + isTranslatorAvailable, +} from '@/services/translators'; import Select from '@/components/Select'; const notSupportedLangs = ['']; @@ -31,6 +36,7 @@ interface TranslatorPopupProps { interface TranslatorType { name: string; label: string; + disabled: boolean; } const TranslatorPopup: React.FC = ({ @@ -71,9 +77,7 @@ const TranslatorPopup: React.FC = ({ const handleProviderChange = (event: React.ChangeEvent) => { const requestedProvider = event.target.value; - const availableTranslators = getTranslators().filter( - (t) => (t.authRequired ? !!token : true) && !t.quotaExceeded, - ); + const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token)); const selectedTranslator = availableTranslators.find((t) => t.name === requestedProvider) || availableTranslators[0]!; if (selectedTranslator) { @@ -84,15 +88,11 @@ const TranslatorPopup: React.FC = ({ }; useEffect(() => { - const availableProviders = translators.map((t) => { - let label = t.label; - if (t.authRequired && !token) { - label = `${label} (${_('Login Required')})`; - } else if (t.quotaExceeded) { - label = `${label} (${_('Quota Exceeded')})`; - } - return { name: t.name, label }; - }); + const availableProviders = translators.map((t) => ({ + name: t.name, + label: getTranslatorDisplayLabel(t, !!token, _), + disabled: !!t.disabled, + })); setProviders(availableProviders); // eslint-disable-next-line react-hooks/exhaustive-deps }, [translators]); @@ -214,7 +214,11 @@ const TranslatorPopup: React.FC = ({ className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100' value={provider} onChange={handleProviderChange} - options={providers.map(({ name: value, label }) => ({ value, label }))} + options={providers.map(({ name: value, label, disabled }) => ({ + value, + label, + disabled, + }))} /> diff --git a/apps/readest-app/src/components/Select.tsx b/apps/readest-app/src/components/Select.tsx index df4bd0a3..75d8eeac 100644 --- a/apps/readest-app/src/components/Select.tsx +++ b/apps/readest-app/src/components/Select.tsx @@ -4,6 +4,7 @@ import React from 'react'; type Option = { value: string; label: string; + disabled?: boolean; }; type SelectProps = { @@ -28,6 +29,7 @@ export default function Select({ onKeyDown={(e) => e.stopPropagation()} className={clsx( 'select bg-base-200 h-8 min-h-8 max-w-[60%] truncate rounded-md border-none text-sm', + 'focus:outline-none focus:ring-0 focus-visible:outline-none', className, )} disabled={disabled} @@ -35,8 +37,8 @@ export default function Select({ textAlignLast: 'end', }} > - {options.map(({ value, label }) => ( - ))} diff --git a/apps/readest-app/src/components/settings/LangPanel.tsx b/apps/readest-app/src/components/settings/LangPanel.tsx index bc3240ea..08e6d1dc 100644 --- a/apps/readest-app/src/components/settings/LangPanel.tsx +++ b/apps/readest-app/src/components/settings/LangPanel.tsx @@ -6,7 +6,11 @@ import { useReaderStore } from '@/store/readerStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; import { saveViewSettings } from '@/helpers/settings'; -import { getTranslators } from '@/services/translators'; +import { + getTranslatorDisplayLabel, + getTranslators, + isTranslatorAvailable, +} from '@/services/translators'; import { useResetViewSettings } from '@/hooks/useResetSettings'; import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants'; import { ConvertChineseVariant } from '@/types/book'; @@ -80,25 +84,19 @@ const LangPanel: React.FC = ({ bookKey, onRegisterReset }; const getTranslationProviderOptions = () => { - const translators = getTranslators(); - const availableProviders = translators.map((t) => { - let label = t.label; - if (t.authRequired && !token) { - label = `${label} (${_('Login Required')})`; - } else if (t.quotaExceeded) { - label = `${label} (${_('Quota Exceeded')})`; - } - return { value: t.name, label }; - }); - return availableProviders; + return getTranslators().map((t) => ({ + value: t.name, + label: getTranslatorDisplayLabel(t, !!token, _), + // Providers marked `disabled` (e.g. upstream relay is down) stay in the + // dropdown so users can see them, but cannot be selected. + disabled: !!t.disabled, + })); }; const getCurrentTranslationProviderOption = () => { const value = translationProvider; const allProviders = getTranslationProviderOptions(); - const availableTranslators = getTranslators().filter( - (t) => (t.authRequired ? !!token : true) && !t.quotaExceeded, - ); + const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token)); const currentProvider = availableTranslators.find((t) => t.name === value) ? value : availableTranslators[0]?.name; diff --git a/apps/readest-app/src/hooks/useTranslator.ts b/apps/readest-app/src/hooks/useTranslator.ts index 493f1802..a5824b91 100644 --- a/apps/readest-app/src/hooks/useTranslator.ts +++ b/apps/readest-app/src/hooks/useTranslator.ts @@ -1,6 +1,12 @@ import { useState, useCallback, useEffect } from 'react'; import { useAuth } from '@/context/AuthContext'; -import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators'; +import { + ErrorCodes, + getTranslator, + getTranslators, + isTranslatorAvailable, + TranslatorName, +} from '@/services/translators'; import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators'; import { polish, preprocess } from '@/services/translators'; import { eventDispatcher } from '@/utils/event'; @@ -26,9 +32,7 @@ export function useTranslator({ }, [provider, sourceLang, targetLang]); useEffect(() => { - const availableTranslators = getTranslators().filter( - (t) => (t.authRequired ? !!token : true) && !t.quotaExceeded, - ); + const availableTranslators = getTranslators().filter((t) => isTranslatorAvailable(t, !!token)); const selectedTranslator = availableTranslators.find((t) => t.name === provider) || availableTranslators[0]!; const selectedProviderName = selectedTranslator.name as TranslatorName; diff --git a/apps/readest-app/src/services/translators/providers/index.ts b/apps/readest-app/src/services/translators/providers/index.ts index 7f4b557c..7c395f12 100644 --- a/apps/readest-app/src/services/translators/providers/index.ts +++ b/apps/readest-app/src/services/translators/providers/index.ts @@ -38,3 +38,45 @@ export const getTranslator = (name: TranslatorName): TranslationProvider | undef export const getTranslators = (): TranslationProvider[] => { return availableTranslators; }; + +/** + * Single source of truth for "can this provider actually be used right now?". + * Used by auto-selection / fallback logic in `useTranslator`, the settings + * panel, and the translator popup. Disabled providers (e.g. temporarily down + * upstream services) are still returned from `getTranslators()` so the UI can + * render them greyed out, but this predicate excludes them so they can never + * be chosen or fallen back to. + */ +export const isTranslatorAvailable = ( + translator: TranslationProvider, + hasToken: boolean, +): boolean => { + if (translator.disabled) return false; + if (translator.quotaExceeded) return false; + if (translator.authRequired && !hasToken) return false; + return true; +}; + +/** + * Builds the user-facing dropdown label for a provider, appending a short + * status suffix when the provider is unavailable. Kept next to + * `isTranslatorAvailable` so the two stay in sync when a new unavailability + * reason is added. The `_` translation function is passed in so this module + * stays free of React imports. + */ +export const getTranslatorDisplayLabel = ( + translator: TranslationProvider, + hasToken: boolean, + _: (key: string) => string, +): string => { + if (translator.disabled) { + return `${translator.label} (${_('Unavailable')})`; + } + if (translator.authRequired && !hasToken) { + return `${translator.label} (${_('Login Required')})`; + } + if (translator.quotaExceeded) { + return `${translator.label} (${_('Quota Exceeded')})`; + } + return translator.label; +}; diff --git a/apps/readest-app/src/services/translators/providers/yandex.ts b/apps/readest-app/src/services/translators/providers/yandex.ts index 23a90534..155d76ce 100644 --- a/apps/readest-app/src/services/translators/providers/yandex.ts +++ b/apps/readest-app/src/services/translators/providers/yandex.ts @@ -49,6 +49,10 @@ export const yandexProvider: TranslationProvider = { name: 'yandex', label: _('Yandex Translate'), authRequired: false, + // The upstream translate.toil.cc relay is currently down. Keep the + // implementation in tree so we can re-enable it simply by flipping this + // flag to `false` (or deleting the line) once the service is healthy. + disabled: true, translate: async (texts: string[], sourceLang: string, targetLang: string): Promise => { if (!texts.length) return []; diff --git a/apps/readest-app/src/services/translators/types.ts b/apps/readest-app/src/services/translators/types.ts index 2590858a..fa38657e 100644 --- a/apps/readest-app/src/services/translators/types.ts +++ b/apps/readest-app/src/services/translators/types.ts @@ -5,6 +5,14 @@ export interface TranslationProvider { label: string; authRequired?: boolean; quotaExceeded?: boolean; + /** + * Marks a provider as temporarily unavailable. Disabled providers are + * filtered out of `getTranslators()` / `getTranslator()`, so the UI never + * lists them and the fallback logic in `useTranslator` skips over them. + * Flip back to `false` (or delete the field) once the provider is healthy + * again — no other code changes required. + */ + disabled?: boolean; translate: ( texts: string[], sourceLang: string,