feat(reader): add in-reader text translation with Google/Azure fallback for EPUB

This commit is contained in:
neon
2026-05-14 10:41:33 -06:00
parent db2b3f9696
commit 57960efecb
12 changed files with 1272 additions and 1 deletions
+28
View File
@@ -21,6 +21,8 @@ import SelectionPopup from './epub/components/SelectionPopup.vue'
import ReaderSearchPanel from './epub/components/ReaderSearchPanel.vue'
import NoteDialog from './epub/components/NoteDialog.vue'
import DictionaryPopover from './epub/components/DictionaryPopover.vue'
import TranslationPopover from './epub/components/TranslationPopover.vue'
import TranslationSheet from './epub/components/TranslationSheet.vue'
import PdfV4ReaderView from './pdf-v4/PdfV4ReaderView.vue'
import CbzReaderView from './cbz/CbzReaderView.vue'
import AudiobookReaderView from './audiobook/AudiobookReaderView.vue'
@@ -101,6 +103,11 @@ const showDictionary = ref(false)
const dictionaryWord = ref('')
const dictionaryPosition = ref({ x: 0, y: 0, showBelow: false })
const showTranslation = ref(false)
const translationText = ref('')
const translationPosition = ref({ x: 0, y: 0, showBelow: false })
const isMobile = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
function handleDefine() {
dictionaryWord.value = selection.text.value
dictionaryPosition.value = {
@@ -112,6 +119,17 @@ function handleDefine() {
showDictionary.value = true
}
function handleTranslate() {
translationText.value = selection.text.value
translationPosition.value = {
x: selection.position.value.x,
y: selection.position.value.y,
showBelow: selection.showBelow.value,
}
selection.dismiss()
showTranslation.value = true
}
function onRelocateHandler(detail: RelocateDetail) {
progress.onRelocate(detail)
onActivity()
@@ -434,6 +452,7 @@ function closeSearch() {
@copy="selection.dismiss()"
@highlight="handleHighlight"
@search="() => openSearchWithText(selection.text.value)"
@translate="handleTranslate"
@define="handleDefine"
@note="selection.openNoteDialog()"
@deleteAnnotation="handleDeleteAnnotation"
@@ -447,6 +466,15 @@ function closeSearch() {
:lang="bookLanguage"
@close="showDictionary = false"
/>
<TranslationPopover
v-if="showTranslation && !isMobile"
:text="translationText"
:position="translationPosition"
@close="showTranslation = false"
/>
<TranslationSheet v-if="showTranslation && isMobile" :text="translationText" @close="showTranslation = false" />
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BookA, Copy, FileText, Highlighter, Search, Trash2 } from 'lucide-vue-next'
import { BookA, Copy, FileText, Highlighter, Languages, Search, Trash2 } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
@@ -15,6 +15,7 @@ const emit = defineEmits<{
copy: []
highlight: [color: string, style: string]
search: []
translate: []
define: []
note: []
deleteAnnotation: [id: number]
@@ -115,6 +116,18 @@ function onCopy() {
<TooltipContent>Search</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
@click="emit('translate')"
>
<Languages :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>Translate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Copy, RefreshCw } from 'lucide-vue-next'
import type { SupportedLanguage, TranslationResult } from '../services/translation.types'
const props = defineProps<{
originalText: string
loading: boolean
error: string | null
result: TranslationResult | null
targetLang: string
languages: SupportedLanguage[]
}>()
const emit = defineEmits<{
changeLanguage: [code: string]
retry: []
copy: []
}>()
const selectedLangName = computed(() => props.languages.find((l) => l.code === props.targetLang)?.name ?? props.targetLang)
function handleLangChange(event: Event) {
emit('changeLanguage', (event.target as HTMLSelectElement).value)
}
</script>
<template>
<div class="p-4 flex flex-col gap-3 min-w-0">
<!-- Original text -->
<div>
<p class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">Original</p>
<div class="max-h-20 overflow-y-auto">
<p class="text-sm text-foreground/70 leading-relaxed break-words">{{ originalText }}</p>
</div>
</div>
<div class="border-t border-border" />
<!-- Translation area -->
<div>
<p class="text-[10px] font-medium uppercase tracking-wide text-muted-foreground mb-1">Translation</p>
<div v-if="loading" class="flex items-center gap-2 py-2">
<div class="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin shrink-0" />
<span class="text-sm text-muted-foreground">Translating...</span>
</div>
<div v-else-if="error" class="py-2">
<p class="text-sm text-destructive mb-2">{{ error }}</p>
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" @click="emit('retry')">
<RefreshCw :size="12" />
Retry
</button>
</div>
<div v-else-if="result" class="max-h-32 overflow-y-auto">
<p class="text-sm text-foreground leading-relaxed break-words">{{ result.translatedText }}</p>
</div>
<div v-else class="py-2">
<p class="text-sm text-muted-foreground italic">No translation yet</p>
</div>
</div>
<!-- Bottom row: language selector | provider chip | copy button -->
<div class="flex items-center gap-2 pt-1 border-t border-border">
<select
class="flex-1 text-xs bg-muted text-foreground rounded px-2 py-1 border border-border focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer min-w-0 truncate"
:value="targetLang"
:title="selectedLangName"
@change="handleLangChange"
>
<option v-for="lang in languages" :key="lang.code" :value="lang.code">{{ lang.name }}</option>
</select>
<span v-if="result" class="shrink-0 px-2 py-0.5 rounded-full text-[10px] font-medium bg-secondary text-secondary-foreground whitespace-nowrap">
via {{ result.provider }}
</span>
<button
v-if="result"
class="shrink-0 flex items-center justify-center w-7 h-7 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Copy translation"
@click="emit('copy')"
>
<Copy :size="14" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import TranslationPanel from './TranslationPanel.vue'
import { useTranslation } from '../composables/useTranslation'
const props = defineProps<{
text: string
position: { x: number; y: number; showBelow: boolean }
}>()
const emit = defineEmits<{
close: []
}>()
const { supportedLanguages, targetLang, loading, error, result, setTargetLang, translate, reset } = useTranslation()
const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref({ left: '0px', top: '0px' })
let resizeObserver: ResizeObserver | null = null
const VIEWPORT_MARGIN = 8
const FALLBACK_WIDTH = 320
const FALLBACK_HEIGHT = 240
function clamp(value: number, min: number, max: number) {
if (max < min) return min
return Math.min(Math.max(value, min), max)
}
function updatePosition() {
if (typeof window === 'undefined') return
const popoverWidth = popoverRef.value?.offsetWidth || FALLBACK_WIDTH
const popoverHeight = popoverRef.value?.offsetHeight || FALLBACK_HEIGHT
const desiredLeft = props.position.x - popoverWidth / 2
const desiredTop = props.position.showBelow ? props.position.y : props.position.y - popoverHeight
popoverStyle.value = {
left: `${clamp(desiredLeft, VIEWPORT_MARGIN, window.innerWidth - popoverWidth - VIEWPORT_MARGIN)}px`,
top: `${clamp(desiredTop, VIEWPORT_MARGIN, window.innerHeight - popoverHeight - VIEWPORT_MARGIN)}px`,
}
}
async function runTranslation() {
reset()
await translate(props.text, targetLang.value)
}
function handleChangeLanguage(code: string) {
setTargetLang(code)
runTranslation()
}
function handleCopy() {
if (result.value) {
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
}
}
onMounted(async () => {
await nextTick()
updatePosition()
window.addEventListener('resize', updatePosition)
if (typeof ResizeObserver !== 'undefined' && popoverRef.value) {
resizeObserver = new ResizeObserver(() => updatePosition())
resizeObserver.observe(popoverRef.value)
}
await runTranslation()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePosition)
resizeObserver?.disconnect()
resizeObserver = null
})
watch(
() => [props.position.x, props.position.y, props.position.showBelow],
() => updatePosition(),
{ immediate: true },
)
watch([loading, result, error], async () => {
await nextTick()
updatePosition()
})
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[61]" @click="emit('close')" />
<div ref="popoverRef" class="fixed z-[62] select-none" :style="popoverStyle" @mousedown.stop @click.stop>
<div
class="bg-card text-card-foreground rounded-lg shadow-xl border border-border w-80 min-w-64 min-h-28 max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] overflow-auto resize"
>
<TranslationPanel
:originalText="text"
:loading="loading"
:error="error"
:result="result"
:targetLang="targetLang"
:languages="supportedLanguages"
@changeLanguage="handleChangeLanguage"
@retry="runTranslation"
@copy="handleCopy"
/>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import TranslationPanel from './TranslationPanel.vue'
import { useTranslation } from '../composables/useTranslation'
const props = defineProps<{
text: string
}>()
const emit = defineEmits<{
close: []
}>()
const { supportedLanguages, targetLang, loading, error, result, setTargetLang, translate, reset } = useTranslation()
async function runTranslation() {
reset()
await translate(props.text, targetLang.value)
}
function handleChangeLanguage(code: string) {
setTargetLang(code)
runTranslation()
}
function handleCopy() {
if (result.value) {
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
}
}
function handleOpenChange(open: boolean) {
if (!open) emit('close')
}
onMounted(runTranslation)
</script>
<template>
<Sheet :open="true" @update:open="handleOpenChange">
<SheetContent side="bottom" class="p-0 rounded-t-xl max-h-[60vh] overflow-y-auto">
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1">
<div class="w-9 h-1 rounded-full bg-muted-foreground/30" />
</div>
<TranslationPanel
:originalText="text"
:loading="loading"
:error="error"
:result="result"
:targetLang="targetLang"
:languages="supportedLanguages"
@changeLanguage="handleChangeLanguage"
@retry="runTranslation"
@copy="handleCopy"
/>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TranslationResult } from '../../services/translation.types'
vi.mock('../../services/google-translate', () => ({
translateWithGoogle: vi.fn<(text: string, targetLang: string) => Promise<TranslationResult>>(),
GoogleTranslateError: class GoogleTranslateError extends Error {
constructor(msg: string) {
super(msg)
this.name = 'GoogleTranslateError'
}
},
}))
vi.mock('../../services/azure-translate', () => ({
translateWithAzure: vi.fn<(text: string, targetLang: string) => Promise<TranslationResult>>(),
resetTokenCache: vi.fn<() => void>(),
AzureTranslateError: class AzureTranslateError extends Error {
constructor(msg: string) {
super(msg)
this.name = 'AzureTranslateError'
}
},
}))
import { translateWithGoogle } from '../../services/google-translate'
import { translateWithAzure } from '../../services/azure-translate'
import { SUPPORTED_LANGUAGES, TRANSLATION_CHAR_LIMIT } from '../../services/translation.types'
const mockGoogle = vi.mocked(translateWithGoogle)
const mockAzure = vi.mocked(translateWithAzure)
const GOOGLE_SUCCESS: TranslationResult = { translatedText: 'Bonjour', detectedSourceLang: 'en', provider: 'Google' }
const AZURE_SUCCESS: TranslationResult = { translatedText: 'Salut', detectedSourceLang: 'en', provider: 'Azure' }
async function loadComposable() {
const mod = await import('../useTranslation')
return mod.useTranslation()
}
function makeLocalStorageMock(getItemImpl: (() => string | null) | (string | null) = null, setItemImpl?: () => void): Storage {
const getItem =
typeof getItemImpl === 'function'
? vi.fn<(key: string) => string | null>().mockImplementation(getItemImpl)
: vi.fn<(key: string) => string | null>().mockReturnValue(getItemImpl)
const setItem = setItemImpl
? vi.fn<(key: string, value: string) => void>().mockImplementation(setItemImpl)
: vi.fn<(key: string, value: string) => void>()
return { getItem, setItem, removeItem: vi.fn<(key: string) => void>() } as unknown as Storage
}
describe('useTranslation', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', makeLocalStorageMock())
mockGoogle.mockReset()
mockAzure.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.resetModules()
})
// -------------------------------------------------------------------------
// Initial state
// -------------------------------------------------------------------------
it('starts with loading=false, error=null, result=null', async () => {
const { loading, error, result } = await loadComposable()
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
expect(result.value).toBeNull()
})
it('exposes supportedLanguages matching the types constant', async () => {
const { supportedLanguages } = await loadComposable()
expect(supportedLanguages).toStrictEqual(SUPPORTED_LANGUAGES)
})
// -------------------------------------------------------------------------
// Language persistence
// -------------------------------------------------------------------------
it('defaults to "en" when localStorage has no stored value', async () => {
const { targetLang } = await loadComposable()
expect(targetLang.value).toBe('en')
})
it('reads persisted lang from localStorage on init', async () => {
vi.stubGlobal('localStorage', makeLocalStorageMock('fr'))
const { targetLang } = await loadComposable()
expect(targetLang.value).toBe('fr')
})
it('falls back to "en" when localStorage returns an unsupported lang code', async () => {
vi.stubGlobal('localStorage', makeLocalStorageMock('xx'))
const { targetLang } = await loadComposable()
expect(targetLang.value).toBe('en')
})
it('falls back to "en" when localStorage.getItem throws', async () => {
vi.stubGlobal(
'localStorage',
makeLocalStorageMock(() => {
throw new Error('unavailable')
}),
)
const { targetLang } = await loadComposable()
expect(targetLang.value).toBe('en')
})
it('setTargetLang updates targetLang and persists to localStorage', async () => {
const setItemMock = vi.fn<(key: string, value: string) => void>()
vi.stubGlobal('localStorage', {
getItem: vi.fn<(key: string) => string | null>().mockReturnValue(null),
setItem: setItemMock,
removeItem: vi.fn<(key: string) => void>(),
} as unknown as Storage)
const { targetLang, setTargetLang } = await loadComposable()
setTargetLang('ja')
expect(targetLang.value).toBe('ja')
expect(setItemMock).toHaveBeenCalledWith('translation_target_lang', 'ja')
})
it('setTargetLang does not throw when localStorage.setItem throws', async () => {
vi.stubGlobal(
'localStorage',
makeLocalStorageMock(null, () => {
throw new Error('quota exceeded')
}),
)
const { setTargetLang } = await loadComposable()
expect(() => setTargetLang('de')).not.toThrow()
})
// -------------------------------------------------------------------------
// Character limit
// -------------------------------------------------------------------------
it('sets error and skips API calls when text exceeds limit', async () => {
const { translate, error, result, loading } = await loadComposable()
const longText = 'a'.repeat(TRANSLATION_CHAR_LIMIT + 1)
await translate(longText, 'fr')
expect(error.value).toMatch(/too long/i)
expect(result.value).toBeNull()
expect(loading.value).toBe(false)
expect(mockGoogle).not.toHaveBeenCalled()
expect(mockAzure).not.toHaveBeenCalled()
})
it('accepts text exactly at the character limit', async () => {
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
const { translate, error } = await loadComposable()
const exactText = 'a'.repeat(TRANSLATION_CHAR_LIMIT)
await translate(exactText, 'fr')
expect(error.value).toBeNull()
expect(mockGoogle).toHaveBeenCalled()
})
it('sets error when trimmed text is empty', async () => {
const { translate, error } = await loadComposable()
await translate(' ', 'fr')
expect(error.value).toMatch(/nothing to translate/i)
expect(mockGoogle).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// Google success path
// -------------------------------------------------------------------------
it('returns Google result on success', async () => {
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
const { translate, result, loading, error } = await loadComposable()
await translate('Hello', 'fr')
expect(result.value).toEqual(GOOGLE_SUCCESS)
expect(error.value).toBeNull()
expect(loading.value).toBe(false)
})
it('sets loading=true during translation then false after', async () => {
const states: boolean[] = []
let resolve!: (r: typeof GOOGLE_SUCCESS) => void
mockGoogle.mockReturnValueOnce(
new Promise((res) => {
resolve = res
}),
)
const { translate, loading } = await loadComposable()
const translatePromise = translate('Hello', 'fr')
states.push(loading.value)
resolve(GOOGLE_SUCCESS)
await translatePromise
states.push(loading.value)
expect(states).toEqual([true, false])
})
it('trims whitespace from input before calling APIs', async () => {
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
const { translate } = await loadComposable()
await translate(' Hello ', 'fr')
expect(mockGoogle).toHaveBeenCalledWith('Hello', 'fr')
})
// -------------------------------------------------------------------------
// Azure fallback
// -------------------------------------------------------------------------
it('falls back to Azure when Google throws', async () => {
mockGoogle.mockRejectedValueOnce(new Error('google fail'))
mockAzure.mockResolvedValueOnce(AZURE_SUCCESS)
const { translate, result, error } = await loadComposable()
await translate('Hello', 'fr')
expect(result.value).toEqual(AZURE_SUCCESS)
expect(error.value).toBeNull()
expect(mockAzure).toHaveBeenCalledWith('Hello', 'fr')
})
it('sets error when both Google and Azure throw', async () => {
mockGoogle.mockRejectedValueOnce(new Error('google fail'))
mockAzure.mockRejectedValueOnce(new Error('azure fail'))
const { translate, result, error, loading } = await loadComposable()
await translate('Hello', 'fr')
expect(result.value).toBeNull()
expect(error.value).toMatch(/translation failed/i)
expect(loading.value).toBe(false)
})
// -------------------------------------------------------------------------
// reset()
// -------------------------------------------------------------------------
it('reset() clears result, error, and loading state', async () => {
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
const { translate, reset, result, error, loading } = await loadComposable()
await translate('Hello', 'fr')
expect(result.value).not.toBeNull()
reset()
expect(result.value).toBeNull()
expect(error.value).toBeNull()
expect(loading.value).toBe(false)
})
// -------------------------------------------------------------------------
// Subsequent calls clear previous state
// -------------------------------------------------------------------------
it('clears previous result and error at the start of a new translate call', async () => {
mockGoogle.mockRejectedValueOnce(new Error('fail'))
mockAzure.mockRejectedValueOnce(new Error('fail'))
const { translate, error, result } = await loadComposable()
await translate('Hello', 'fr')
expect(error.value).not.toBeNull()
mockGoogle.mockResolvedValueOnce(GOOGLE_SUCCESS)
await translate('World', 'es')
expect(error.value).toBeNull()
expect(result.value).toEqual(GOOGLE_SUCCESS)
})
})
@@ -0,0 +1,91 @@
import { ref } from 'vue'
import { translateWithGoogle } from '../services/google-translate'
import { translateWithAzure } from '../services/azure-translate'
import { SUPPORTED_LANGUAGES, TRANSLATION_CHAR_LIMIT } from '../services/translation.types'
import type { TranslationResult } from '../services/translation.types'
const STORAGE_KEY = 'translation_target_lang'
const DEFAULT_LANG = 'en'
function loadPersistedLang(): string {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && SUPPORTED_LANGUAGES.some((l) => l.code === stored)) {
return stored
}
} catch {
// localStorage unavailable (e.g. SSR or private mode)
}
return DEFAULT_LANG
}
function persistLang(code: string): void {
try {
localStorage.setItem(STORAGE_KEY, code)
} catch {
// ignore
}
}
export function useTranslation() {
const targetLang = ref<string>(loadPersistedLang())
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<TranslationResult | null>(null)
function setTargetLang(code: string): void {
targetLang.value = code
persistLang(code)
}
async function translate(text: string, lang: string): Promise<void> {
const trimmed = text.trim()
if (trimmed.length > TRANSLATION_CHAR_LIMIT) {
error.value = `Selection is too long. Please select ${TRANSLATION_CHAR_LIMIT} characters or fewer.`
result.value = null
return
}
if (!trimmed) {
error.value = 'Nothing to translate.'
result.value = null
return
}
loading.value = true
error.value = null
result.value = null
try {
const res = await translateWithGoogle(trimmed, lang)
result.value = res
} catch {
try {
const res = await translateWithAzure(trimmed, lang)
result.value = res
} catch {
error.value = 'Translation failed. Check your connection and try again.'
}
} finally {
loading.value = false
}
}
function reset(): void {
loading.value = false
error.value = null
result.value = null
}
return {
supportedLanguages: SUPPORTED_LANGUAGES,
targetLang,
loading,
error,
result,
setTargetLang,
translate,
reset,
}
}
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AzureTranslateError, translateWithAzure, resetTokenCache } from '../azure-translate'
describe('translateWithAzure', () => {
let fetchMock: ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
beforeEach(() => {
resetTokenCache()
fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
resetTokenCache()
})
function mockText(text: string): Response {
return {
ok: true,
status: 200,
text: async () => text,
json: async () => ({}),
} as unknown as Response
}
function mockOk(data: unknown): Response {
return {
ok: true,
status: 200,
text: async () => '',
json: async () => data,
} as unknown as Response
}
function mockStatus(status: number): Response {
return {
ok: false,
status,
text: async () => '',
json: async () => ({}),
} as unknown as Response
}
function makeAzureResponse(translatedText: string, detectedLang = 'en'): unknown {
return [
{
translations: [{ text: translatedText }],
detectedLanguage: { language: detectedLang },
},
]
}
// -------------------------------------------------------------------------
// Success paths
// -------------------------------------------------------------------------
it('fetches auth token then translates successfully', async () => {
fetchMock.mockResolvedValueOnce(mockText('test-jwt-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Bonjour')))
const result = await translateWithAzure('Hello', 'fr')
expect(result.translatedText).toBe('Bonjour')
expect(result.provider).toBe('Azure')
expect(result.detectedSourceLang).toBe('en')
})
it('sends auth token in Authorization header', async () => {
fetchMock.mockResolvedValueOnce(mockText('my-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
await translateWithAzure('Hello', 'es')
const translateCall = fetchMock.mock.calls[1]!
const init = translateCall[1] as RequestInit
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer my-token')
})
it('sends correct target language in URL', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
await translateWithAzure('Hello', 'es')
const translateUrl = fetchMock.mock.calls[1]![0] as string
expect(translateUrl).toContain('to=es')
expect(translateUrl).toContain('api-version=3.0')
})
it('sends text as POST body with Text field', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
await translateWithAzure('Hello', 'es')
const init = fetchMock.mock.calls[1]![1] as RequestInit
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual([{ Text: 'Hello' }])
})
it('uses detectedSourceLang from response', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola', 'fr')))
const result = await translateWithAzure('Bonjour', 'es')
expect(result.detectedSourceLang).toBe('fr')
})
it('falls back to "auto" when detectedLanguage is missing from response', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: 'Hola' }] }]))
const result = await translateWithAzure('Hello', 'es')
expect(result.detectedSourceLang).toBe('auto')
})
it('falls back to "auto" when detectedLanguage.language is not a string', async () => {
fetchMock
.mockResolvedValueOnce(mockText('token'))
.mockResolvedValueOnce(mockOk([{ translations: [{ text: 'Hola' }], detectedLanguage: { language: 42 } }]))
const result = await translateWithAzure('Hello', 'es')
expect(result.detectedSourceLang).toBe('auto')
})
// -------------------------------------------------------------------------
// Token caching
// -------------------------------------------------------------------------
it('reuses cached token within TTL without fetching again', async () => {
fetchMock
.mockResolvedValueOnce(mockText('cached-token'))
.mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
.mockResolvedValueOnce(mockOk(makeAzureResponse('Hola2')))
await translateWithAzure('Hello', 'es')
await translateWithAzure('World', 'es')
// auth token fetch only called once
const authCalls = fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))
expect(authCalls).toHaveLength(1)
})
it('invalidates token on 401 response and clears cache', async () => {
fetchMock.mockResolvedValueOnce(mockText('bad-token')).mockResolvedValueOnce(mockStatus(401))
await expect(translateWithAzure('Hello', 'es')).rejects.toThrow('Auth token rejected (401)')
// after 401, cache should be cleared — next call fetches a new token
fetchMock.mockResolvedValueOnce(mockText('new-token')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
const result = await translateWithAzure('Hello', 'es')
expect(result.translatedText).toBe('Hola')
expect(fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))).toHaveLength(2)
})
it('resetTokenCache forces re-fetch on next call', async () => {
fetchMock.mockResolvedValueOnce(mockText('token-1')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola')))
await translateWithAzure('Hello', 'es')
resetTokenCache()
fetchMock.mockResolvedValueOnce(mockText('token-2')).mockResolvedValueOnce(mockOk(makeAzureResponse('Hola2')))
await translateWithAzure('Hello', 'es')
const authCalls = fetchMock.mock.calls.filter((c) => (c[0] as string).includes('translate/auth'))
expect(authCalls).toHaveLength(2)
})
// -------------------------------------------------------------------------
// Auth error paths
// -------------------------------------------------------------------------
it('throws AzureTranslateError when auth endpoint returns non-OK status', async () => {
fetchMock.mockResolvedValueOnce(mockStatus(503))
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(AzureTranslateError)
expect((err as AzureTranslateError).message).toContain('Auth HTTP 503')
})
it('throws AzureTranslateError when auth endpoint returns empty token', async () => {
fetchMock.mockResolvedValueOnce(mockText(' '))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty auth token')
})
it('throws AzureTranslateError when auth fetch throws a network error', async () => {
fetchMock.mockRejectedValueOnce(new Error('network'))
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(AzureTranslateError)
expect((err as AzureTranslateError).message).toContain('Auth network error')
})
// -------------------------------------------------------------------------
// Translation error paths
// -------------------------------------------------------------------------
it('throws AzureTranslateError on non-OK translate response', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockStatus(500))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('HTTP 500')
})
it('throws AzureTranslateError when translate network fetch throws', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockRejectedValueOnce(new Error('network'))
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(AzureTranslateError)
expect((err as AzureTranslateError).message).toContain('Network error')
})
it('throws AzureTranslateError when response is not an array', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk({ error: 'bad' }))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow(AzureTranslateError)
})
it('throws AzureTranslateError when response is empty array', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([]))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Unexpected response shape')
})
it('throws AzureTranslateError when translations array is empty', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [] }]))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('No translations in response')
})
it('throws AzureTranslateError when translations is missing', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ other: 'data' }]))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('No translations in response')
})
it('throws AzureTranslateError when translated text is empty string', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: '' }] }]))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty translated text')
})
it('throws AzureTranslateError when translated text is not a string', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce(mockOk([{ translations: [{ text: null }] }]))
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow('Empty translated text')
})
it('throws AzureTranslateError when JSON parsing fails', async () => {
fetchMock.mockResolvedValueOnce(mockText('token')).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('bad json')
},
} as unknown as Response)
await expect(translateWithAzure('Hello', 'fr')).rejects.toThrow(AzureTranslateError)
})
it('error has name AzureTranslateError', async () => {
fetchMock.mockRejectedValueOnce(new Error('network'))
const err = await translateWithAzure('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(AzureTranslateError)
expect((err as AzureTranslateError).name).toBe('AzureTranslateError')
})
})
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GoogleTranslateError, translateWithGoogle } from '../google-translate'
const GOOGLE_URL = 'https://translate.googleapis.com/translate_a/single'
describe('translateWithGoogle', () => {
let fetchMock: ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
beforeEach(() => {
fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function mockOk(data: unknown): Response {
return {
ok: true,
status: 200,
json: async () => data,
} as unknown as Response
}
function mockStatus(status: number): Response {
return {
ok: false,
status,
json: async () => ({}),
} as unknown as Response
}
function makeGoogleResponse(sentences: unknown[][], detectedLang = 'en'): unknown {
return [sentences, null, detectedLang]
}
// -------------------------------------------------------------------------
// Success paths
// -------------------------------------------------------------------------
it('returns translated text from a single-sentence response', async () => {
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['Bonjour', 'Hello', null, null, 10]], 'en')))
const result = await translateWithGoogle('Hello', 'fr')
expect(result.translatedText).toBe('Bonjour')
expect(result.provider).toBe('Google')
expect(result.detectedSourceLang).toBe('en')
})
it('concatenates multiple sentences into a single string', async () => {
fetchMock.mockResolvedValueOnce(
mockOk(
makeGoogleResponse(
[
['Bonjour', 'Hello', null],
[' monde', ' world', null],
],
'en',
),
),
)
const result = await translateWithGoogle('Hello world', 'fr')
expect(result.translatedText).toBe('Bonjour monde')
})
it('uses "auto" as detectedSourceLang when data[2] is missing', async () => {
fetchMock.mockResolvedValueOnce(mockOk([[[`Hola`, 'Hello']]]))
const result = await translateWithGoogle('Hello', 'es')
expect(result.detectedSourceLang).toBe('auto')
})
it('uses "auto" as detectedSourceLang when data[2] is not a string', async () => {
fetchMock.mockResolvedValueOnce(mockOk([[[`Hola`, 'Hello']], null, 42]))
const result = await translateWithGoogle('Hello', 'es')
expect(result.detectedSourceLang).toBe('auto')
})
it('builds correct URL with all required query params', async () => {
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['Hola', 'Hello']], 'en')))
await translateWithGoogle('Hello', 'es')
const calledUrl = fetchMock.mock.calls[0]![0] as string
expect(calledUrl).toContain('client=gtx')
expect(calledUrl).toContain('sl=auto')
expect(calledUrl).toContain('tl=es')
expect(calledUrl).toContain(encodeURIComponent('Hello'))
expect(calledUrl).toContain(GOOGLE_URL)
})
it('encodes special characters in the query text', async () => {
fetchMock.mockResolvedValueOnce(mockOk(makeGoogleResponse([['résultat', 'result']], 'en')))
await translateWithGoogle('café & résumé', 'fr')
const calledUrl = fetchMock.mock.calls[0]![0] as string
// URLSearchParams encodes spaces as '+', so decode before asserting
const decodedUrl = decodeURIComponent(calledUrl.replace(/\+/g, ' '))
expect(decodedUrl).toContain('café & résumé')
})
it('skips sentence entries that are not arrays', async () => {
fetchMock.mockResolvedValueOnce(mockOk([[['Hello', 'Hi'], null, ['World', 'World']], null, 'en']))
const result = await translateWithGoogle('Hi World', 'en')
expect(result.translatedText).toBe('HelloWorld')
})
it('skips sentence entries where first element is not a string', async () => {
fetchMock.mockResolvedValueOnce(
mockOk([
[
[42, 'Hello'],
['Valid', 'text'],
],
null,
'en',
]),
)
const result = await translateWithGoogle('Hello text', 'es')
expect(result.translatedText).toBe('Valid')
})
// -------------------------------------------------------------------------
// Error paths
// -------------------------------------------------------------------------
it('throws GoogleTranslateError on network failure', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'))
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(GoogleTranslateError)
expect((err as GoogleTranslateError).message).toContain('Network error')
})
it('throws GoogleTranslateError on non-OK HTTP status', async () => {
fetchMock.mockResolvedValueOnce(mockStatus(429))
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(GoogleTranslateError)
expect((err as GoogleTranslateError).message).toContain('HTTP 429')
})
it('throws GoogleTranslateError on 500 status', async () => {
fetchMock.mockResolvedValueOnce(mockStatus(500))
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow('HTTP 500')
})
it('throws GoogleTranslateError when response is not an array', async () => {
fetchMock.mockResolvedValueOnce(mockOk({ error: 'bad' }))
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
})
it('throws GoogleTranslateError when response is empty array', async () => {
fetchMock.mockResolvedValueOnce(mockOk([]))
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
})
it('throws GoogleTranslateError when data[0] is not an array', async () => {
fetchMock.mockResolvedValueOnce(mockOk(['not-sentences', null, 'en']))
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
})
it('throws GoogleTranslateError when all sentences produce empty string', async () => {
fetchMock.mockResolvedValueOnce(mockOk([[null, null], null, 'en']))
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
})
it('throws GoogleTranslateError when JSON parsing fails', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('invalid json')
},
} as unknown as Response)
await expect(translateWithGoogle('Hello', 'fr')).rejects.toThrow(GoogleTranslateError)
})
it('error has name GoogleTranslateError', async () => {
fetchMock.mockRejectedValueOnce(new Error('network'))
const err = await translateWithGoogle('Hello', 'fr').catch((e: unknown) => e)
expect(err).toBeInstanceOf(GoogleTranslateError)
expect((err as GoogleTranslateError).name).toBe('GoogleTranslateError')
})
})
@@ -0,0 +1,111 @@
import type { TranslationResult } from './translation.types'
const AZURE_AUTH_URL = 'https://edge.microsoft.com/translate/auth'
const AZURE_TRANSLATE_URL = 'https://api-edge.cognitive.microsofttranslator.com/translate'
const TOKEN_TTL_MS = 7 * 60 * 1000
export class AzureTranslateError extends Error {
constructor(message: string) {
super(message)
this.name = 'AzureTranslateError'
}
}
let cachedToken: string | null = null
let tokenExpiresAt: number = 0
export function resetTokenCache(): void {
cachedToken = null
tokenExpiresAt = 0
}
async function getAuthToken(): Promise<string> {
if (cachedToken && Date.now() < tokenExpiresAt) {
return cachedToken
}
let response: Response
try {
response = await fetch(AZURE_AUTH_URL)
} catch (err) {
throw new AzureTranslateError(`Auth network error: ${err instanceof Error ? err.message : String(err)}`)
}
if (!response.ok) {
throw new AzureTranslateError(`Auth HTTP ${response.status}`)
}
const token = await response.text()
if (!token || !token.trim()) {
throw new AzureTranslateError('Empty auth token')
}
cachedToken = token.trim()
tokenExpiresAt = Date.now() + TOKEN_TTL_MS
return cachedToken
}
export async function translateWithAzure(text: string, targetLang: string): Promise<TranslationResult> {
const token = await getAuthToken()
const url = new URL(AZURE_TRANSLATE_URL)
url.searchParams.set('to', targetLang)
url.searchParams.set('api-version', '3.0')
let response: Response
try {
response = await fetch(url.toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([{ Text: text }]),
})
} catch (err) {
throw new AzureTranslateError(`Network error: ${err instanceof Error ? err.message : String(err)}`)
}
if (response.status === 401) {
resetTokenCache()
throw new AzureTranslateError('Auth token rejected (401)')
}
if (!response.ok) {
throw new AzureTranslateError(`HTTP ${response.status}`)
}
let data: unknown
try {
data = await response.json()
} catch {
throw new AzureTranslateError('Failed to parse response')
}
if (!Array.isArray(data) || data.length === 0) {
throw new AzureTranslateError('Unexpected response shape')
}
const first = data[0] as Record<string, unknown>
const translations = first['translations']
if (!Array.isArray(translations) || translations.length === 0) {
throw new AzureTranslateError('No translations in response')
}
const translation = translations[0] as Record<string, unknown>
const translatedText = translation['text']
if (typeof translatedText !== 'string' || !translatedText) {
throw new AzureTranslateError('Empty translated text in response')
}
let detectedSourceLang = 'auto'
const detectedLanguage = first['detectedLanguage']
if (detectedLanguage && typeof detectedLanguage === 'object') {
const lang = (detectedLanguage as Record<string, unknown>)['language']
if (typeof lang === 'string') {
detectedSourceLang = lang
}
}
return { translatedText, detectedSourceLang, provider: 'Azure' }
}
@@ -0,0 +1,61 @@
import type { TranslationResult } from './translation.types'
const GOOGLE_TRANSLATE_URL = 'https://translate.googleapis.com/translate_a/single'
export class GoogleTranslateError extends Error {
constructor(message: string) {
super(message)
this.name = 'GoogleTranslateError'
}
}
export async function translateWithGoogle(text: string, targetLang: string): Promise<TranslationResult> {
const url = new URL(GOOGLE_TRANSLATE_URL)
url.searchParams.set('client', 'gtx')
url.searchParams.set('dt', 't')
url.searchParams.set('sl', 'auto')
url.searchParams.set('tl', targetLang)
url.searchParams.set('q', text)
let response: Response
try {
response = await fetch(url.toString())
} catch (err) {
throw new GoogleTranslateError(`Network error: ${err instanceof Error ? err.message : String(err)}`)
}
if (!response.ok) {
throw new GoogleTranslateError(`HTTP ${response.status}`)
}
let data: unknown
try {
data = await response.json()
} catch {
throw new GoogleTranslateError('Failed to parse response')
}
if (!Array.isArray(data) || data.length === 0) {
throw new GoogleTranslateError('Unexpected response shape')
}
const sentences = data[0]
if (!Array.isArray(sentences)) {
throw new GoogleTranslateError('Unexpected sentences shape')
}
let translatedText = ''
for (const sentence of sentences) {
if (Array.isArray(sentence) && typeof sentence[0] === 'string') {
translatedText += sentence[0]
}
}
if (!translatedText) {
throw new GoogleTranslateError('Empty translation in response')
}
const detectedSourceLang = typeof data[2] === 'string' ? data[2] : 'auto'
return { translatedText, detectedSourceLang, provider: 'Google' }
}
@@ -0,0 +1,25 @@
export const TRANSLATION_CHAR_LIMIT = 500
export interface TranslationResult {
translatedText: string
detectedSourceLang: string
provider: 'Google' | 'Azure'
}
export interface SupportedLanguage {
code: string
name: string
}
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
{ code: 'hi', name: 'Hindi' },
{ code: 'pt', name: 'Portuguese' },
]