forked from akai/readest
fix(translate): disable yandex provider while upstream relay is down (#3765)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TranslatorPopupProps> = ({
|
||||
@@ -71,9 +77,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
|
||||
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
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<TranslatorPopupProps> = ({
|
||||
};
|
||||
|
||||
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<TranslatorPopupProps> = ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
@@ -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 }) => (
|
||||
<option key={value} value={value}>
|
||||
{options.map(({ value, label, disabled: optionDisabled }) => (
|
||||
<option key={value} value={value} disabled={optionDisabled}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -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<SettingsPanelPanelProp> = ({ 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string[]> => {
|
||||
if (!texts.length) return [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user