feat: support translation for profile abount

This commit is contained in:
codytseng
2025-06-29 14:13:18 +08:00
parent e89cfc03e9
commit d3093a1c4e
20 changed files with 290 additions and 133 deletions

View File

@@ -42,7 +42,7 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
)} )}
<SecondaryPageLink <SecondaryPageLink
to={toNoteList({ domain: nip05Domain })} to={toNoteList({ domain: nip05Domain })}
className={`hover:underline truncate ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`} className={`hover:underline truncate text-sm ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
> >
{nip05Domain} {nip05Domain}
</SecondaryPageLink> </SecondaryPageLink>

View File

@@ -5,19 +5,33 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { useMemo } from 'react' import { detectLanguage } from '@/lib/utils'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
EmbeddedHashtag, EmbeddedHashtag,
EmbeddedMention, EmbeddedMention,
EmbeddedNormalUrl, EmbeddedNormalUrl,
EmbeddedWebsocketUrl EmbeddedWebsocketUrl
} from '../Embedded' } from '../Embedded'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { toast } from 'sonner'
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) { export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const { t, i18n } = useTranslation()
const { translateText } = useTranslationService()
const needTranslation = useMemo(() => {
const detected = detectLanguage(about)
if (!detected) return false
if (detected === 'und') return true
return !i18n.language.startsWith(detected)
}, [about, i18n.language])
const [translatedAbout, setTranslatedAbout] = useState<string | null>(null)
const [translating, setTranslating] = useState(false)
const aboutNodes = useMemo(() => { const aboutNodes = useMemo(() => {
if (!about) return null if (!about) return null
const nodes = parseContent(about, [ const nodes = parseContent(translatedAbout ?? about, [
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedNormalUrlParser, EmbeddedNormalUrlParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
@@ -40,7 +54,60 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
} }
}) })
}, [about]) }, [about, translatedAbout])
return <div className={className}>{aboutNodes}</div> const handleTranslate = async () => {
if (translating || translatedAbout) return
setTranslating(true)
translateText(about ?? '')
.then((translated) => {
setTranslatedAbout(translated)
})
.catch((error) => {
toast.error(
'Translation failed: ' +
(error.message || 'An error occurred while translating the about')
)
})
.finally(() => {
setTranslating(false)
})
}
const handleShowOriginal = () => {
setTranslatedAbout(null)
}
return (
<div>
<div className={className}>{aboutNodes}</div>
{needTranslation && (
<div className="mt-2 text-sm">
{translating ? (
<div className="text-muted-foreground">{t('Translating...')}</div>
) : translatedAbout === null ? (
<button
className="text-primary hover:underline"
onClick={(e) => {
e.stopPropagation()
handleTranslate()
}}
>
{t('Translate')}
</button>
) : (
<button
className="text-primary hover:underline"
onClick={(e) => {
e.stopPropagation()
handleShowOriginal()
}}
>
{t('Show original')}
</button>
)}
</div>
)}
</div>
)
} }

View File

@@ -1,19 +1,9 @@
import {
EMAIL_REGEX,
EMBEDDED_EVENT_REGEX,
EMBEDDED_MENTION_REGEX,
EMOJI_REGEX,
HASHTAG_REGEX,
URL_REGEX,
WS_URL_REGEX
} from '@/constants'
import { useTranslatedEvent } from '@/hooks' import { useTranslatedEvent } from '@/hooks'
import { isSupportedKind } from '@/lib/event' import { isSupportedKind } from '@/lib/event'
import { toTranslation } from '@/lib/link' import { toTranslation } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn, detectLanguage } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useTranslationService } from '@/providers/TranslationServiceProvider' import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { franc } from 'franc-min'
import { Languages, Loader } from 'lucide-react' import { Languages, Loader } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -29,68 +19,16 @@ export default function TranslateButton({
}) { }) {
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { translate, showOriginalEvent } = useTranslationService() const { translateEvent, showOriginalEvent } = useTranslationService()
const [translating, setTranslating] = useState(false) const [translating, setTranslating] = useState(false)
const translatedEvent = useTranslatedEvent(event.id) const translatedEvent = useTranslatedEvent(event.id)
const supported = useMemo(() => isSupportedKind(event.kind), [event]) const supported = useMemo(() => isSupportedKind(event.kind), [event])
const needTranslation = useMemo(() => { const needTranslation = useMemo(() => {
const cleanText = event.content const detected = detectLanguage(event.content)
.replace(URL_REGEX, '') if (!detected) return false
.replace(WS_URL_REGEX, '') if (detected === 'und') return true
.replace(EMAIL_REGEX, '') return !i18n.language.startsWith(detected)
.replace(EMBEDDED_MENTION_REGEX, '')
.replace(EMBEDDED_EVENT_REGEX, '')
.replace(HASHTAG_REGEX, '')
.replace(EMOJI_REGEX, '')
.trim()
if (!cleanText) {
return false
}
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(cleanText)) {
return i18n.language !== 'ja'
}
if (/[\u0e00-\u0e7f]/.test(cleanText)) {
return i18n.language !== 'th'
}
if (/[\u4e00-\u9fff]/.test(cleanText)) {
return i18n.language !== 'zh'
}
if (/[\u0600-\u06ff]/.test(cleanText)) {
return i18n.language !== 'ar'
}
if (/[\u0400-\u04ff]/.test(cleanText)) {
return i18n.language !== 'ru'
}
try {
const detectedLang = franc(cleanText)
const langMap: { [key: string]: string } = {
ara: 'ar', // Arabic
deu: 'de', // German
eng: 'en', // English
spa: 'es', // Spanish
fra: 'fr', // French
ita: 'it', // Italian
jpn: 'ja', // Japanese
pol: 'pl', // Polish
por: 'pt', // Portuguese
rus: 'ru', // Russian
cmn: 'zh', // Chinese (Mandarin)
zho: 'zh' // Chinese (alternative code)
}
const normalizedLang = langMap[detectedLang]
if (!normalizedLang) {
return true
}
return !i18n.language.startsWith(normalizedLang)
} catch {
return true
}
}, [event, i18n.language]) }, [event, i18n.language])
if (!supported || !needTranslation) { if (!supported || !needTranslation) {
@@ -101,7 +39,7 @@ export default function TranslateButton({
if (translating) return if (translating) return
setTranslating(true) setTranslating(true)
await translate(event) await translateEvent(event)
.catch((error) => { .catch((error) => {
toast.error( toast.error(
'Translation failed: ' + (error.message || 'An error occurred while translating the note') 'Translation failed: ' + (error.message || 'An error occurred while translating the note')

View File

@@ -275,6 +275,9 @@ export default {
'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.', 'المستخدمون الموثوقون هم الأشخاص الذين تتابعهم والأشخاص الذين يتابعونهم.',
Continue: 'متابعة', Continue: 'متابعة',
'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح', 'Successfully updated mute list': 'تم تحديث قائمة الكتم بنجاح',
'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}' 'No pubkeys found from {url}': 'لم يتم العثور على مفاتيح عامة من {{url}}',
'Translating...': 'جارٍ الترجمة...',
Translate: 'ترجمة',
'Show original': 'عرض الأصل'
} }
} }

View File

@@ -282,6 +282,9 @@ export default {
'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.', 'Vertrauenswürdige Benutzer sind Personen, denen du folgst, und Personen, denen sie folgen.',
Continue: 'Weiter', Continue: 'Weiter',
'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert', 'Successfully updated mute list': 'Stummschalteliste erfolgreich aktualisiert',
'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden' 'No pubkeys found from {url}': 'Keine Pubkeys von {{url}} gefunden',
'Translating...': 'Übersetze...',
Translate: 'Übersetzen',
'Show original': 'Original anzeigen'
} }
} }

View File

@@ -275,6 +275,9 @@ export default {
'Trusted users include people you follow and people they follow.', 'Trusted users include people you follow and people they follow.',
Continue: 'Continue', Continue: 'Continue',
'Successfully updated mute list': 'Successfully updated mute list', 'Successfully updated mute list': 'Successfully updated mute list',
'No pubkeys found from {url}': 'No pubkeys found from {{url}}' 'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
'Translating...': 'Translating...',
Translate: 'Translate',
'Show original': 'Show original'
} }
} }

View File

@@ -280,6 +280,9 @@ export default {
'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.', 'Los usuarios confiables incluyen a las personas que sigues y a las personas que ellos siguen.',
Continue: 'Continuar', Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito', 'Successfully updated mute list': 'Lista de silenciamiento actualizada con éxito',
'No pubkeys found from {url}': 'No se encontraron pubkeys desde {{url}}' 'No pubkeys found from {url}': 'No se encontraron pubkeys desde {{url}}',
'Translating...': 'Traduciendo...',
Translate: 'Traducir',
'Show original': 'Mostrar original'
} }
} }

View File

@@ -280,6 +280,9 @@ export default {
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes quelles suivent.', 'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes quelles suivent.',
Continue: 'Continuer', Continue: 'Continuer',
'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès', 'Successfully updated mute list': 'Liste de sourdine mise à jour avec succès',
'No pubkeys found from {url}': 'Aucun pubkey trouvé à partir de {{url}}' 'No pubkeys found from {url}': 'Aucun pubkey trouvé à partir de {{url}}',
'Translating...': 'Traduction en cours...',
Translate: 'Traduire',
'Show original': 'Afficher loriginal'
} }
} }

View File

@@ -279,6 +279,9 @@ export default {
'Gli utenti fidati includono le persone che segui e le persone che seguono loro.', 'Gli utenti fidati includono le persone che segui e le persone che seguono loro.',
Continue: 'Continua', Continue: 'Continua',
'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo', 'Successfully updated mute list': 'Lista di silenziamento aggiornata con successo',
'No pubkeys found from {url}': 'Nessun pubkey trovato da {{url}}' 'No pubkeys found from {url}': 'Nessun pubkey trovato da {{url}}',
'Translating...': 'Traduzione in corso...',
Translate: 'Traduci',
'Show original': 'Mostra originale'
} }
} }

View File

@@ -277,6 +277,9 @@ export default {
'信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。', '信頼できるユーザーには、あなたがフォローしている人とその人がフォローしている人が含まれます。',
Continue: '続行', Continue: '続行',
'Successfully updated mute list': 'ミュートリストの更新に成功しました', 'Successfully updated mute list': 'ミュートリストの更新に成功しました',
'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした' 'No pubkeys found from {url}': 'URL {{url}} からのpubkeyは見つかりませんでした',
'Translating...': '翻訳中...',
Translate: '翻訳',
'Show original': '原文を表示'
} }
} }

View File

@@ -278,6 +278,9 @@ export default {
'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.', 'Zaufani użytkownicy to osoby, które obserwujesz i osoby, które oni obserwują.',
Continue: 'Kontynuuj', Continue: 'Kontynuuj',
'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników', 'Successfully updated mute list': 'Sukces aktualizacji listy zablokowanych użytkowników',
'No pubkeys found from {url}': 'Nie znaleziono kluczy publicznych z {{url}}' 'No pubkeys found from {url}': 'Nie znaleziono kluczy publicznych z {{url}}',
'Translating...': 'Tłumaczenie...',
Translate: 'Przetłumacz',
'Show original': 'Pokaż oryginał'
} }
} }

View File

@@ -278,6 +278,9 @@ export default {
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.', 'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar', Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso', 'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}' 'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}',
'Translating...': 'Traduzindo...',
Translate: 'Traduzir',
'Show original': 'Mostrar original'
} }
} }

View File

@@ -279,6 +279,9 @@ export default {
'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.', 'Usuários confiáveis incluem pessoas que você segue e pessoas que elas seguem.',
Continue: 'Continuar', Continue: 'Continuar',
'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso', 'Successfully updated mute list': 'Lista de silenciados atualizada com sucesso',
'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}' 'No pubkeys found from {url}': 'Nenhum pubkey encontrado em {{url}}',
'Translating...': 'Traduzindo...',
Translate: 'Traduzir',
'Show original': 'Mostrar original'
} }
} }

View File

@@ -280,6 +280,9 @@ export default {
'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.', 'Доверенные пользователи включают людей, на которых вы подписаны, и людей, на которых они подписаны.',
Continue: 'Продолжить', Continue: 'Продолжить',
'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей', 'Successfully updated mute list': 'Успешно обновлен список заглушенных пользователей',
'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}' 'No pubkeys found from {url}': 'Не найдено pubkeys из {{url}}',
'Translating...': 'Перевод...',
Translate: 'Перевести',
'Show original': 'Показать оригинал'
} }
} }

View File

@@ -274,6 +274,9 @@ export default {
'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม', 'ผู้ใช้ที่เชื่อถือได้รวมถึงผู้ที่คุณติดตามและผู้ที่พวกเขาติดตาม',
Continue: 'ดำเนินการต่อ', Continue: 'ดำเนินการต่อ',
'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ', 'Successfully updated mute list': 'อัปเดตรายการปิดเสียงสำเร็จ',
'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}' 'No pubkeys found from {url}': 'ไม่พบ pubkeys จาก {{url}}',
'Translating...': 'กำลังแปล...',
Translate: 'แปล',
'Show original': 'แสดงต้นฉบับ'
} }
} }

View File

@@ -275,6 +275,9 @@ export default {
'受信任的用户包括您关注的人和他们关注的人。', '受信任的用户包括您关注的人和他们关注的人。',
Continue: '继续', Continue: '继续',
'Successfully updated mute list': '成功更新屏蔽列表', 'Successfully updated mute list': '成功更新屏蔽列表',
'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys' 'No pubkeys found from {url}': '在 {{url}} 中未找到 pubkeys',
'Translating...': '翻译中...',
Translate: '翻译',
'Show original': '显示原文'
} }
} }

View File

@@ -1,4 +1,14 @@
import {
EMAIL_REGEX,
EMBEDDED_EVENT_REGEX,
EMBEDDED_MENTION_REGEX,
EMOJI_REGEX,
HASHTAG_REGEX,
URL_REGEX,
WS_URL_REGEX
} from '@/constants'
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx'
import { franc } from 'franc-min'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -38,3 +48,65 @@ export function isInViewport(el: HTMLElement) {
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
) )
} }
export function detectLanguage(text?: string): string | null {
if (!text) {
return null
}
const cleanText = text
.replace(URL_REGEX, '')
.replace(WS_URL_REGEX, '')
.replace(EMAIL_REGEX, '')
.replace(EMBEDDED_MENTION_REGEX, '')
.replace(EMBEDDED_EVENT_REGEX, '')
.replace(HASHTAG_REGEX, '')
.replace(EMOJI_REGEX, '')
.trim()
if (!cleanText) {
return null
}
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(cleanText)) {
return 'ja'
}
if (/[\u0e00-\u0e7f]/.test(cleanText)) {
return 'th'
}
if (/[\u4e00-\u9fff]/.test(cleanText)) {
return 'zh'
}
if (/[\u0600-\u06ff]/.test(cleanText)) {
return 'ar'
}
if (/[\u0400-\u04ff]/.test(cleanText)) {
return 'ru'
}
try {
const detectedLang = franc(cleanText)
const langMap: { [key: string]: string } = {
ara: 'ar', // Arabic
deu: 'de', // German
eng: 'en', // English
spa: 'es', // Spanish
fra: 'fr', // French
ita: 'it', // Italian
jpn: 'ja', // Japanese
pol: 'pl', // Polish
por: 'pt', // Portuguese
rus: 'ru', // Russian
cmn: 'zh', // Chinese (Mandarin)
zho: 'zh' // Chinese (alternative code)
}
const normalizedLang = langMap[detectedLang]
if (!normalizedLang) {
return 'und'
}
return normalizedLang
} catch {
return 'und'
}
}

View File

@@ -7,12 +7,14 @@ import { createContext, useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
const translatedEventCache: Record<string, Event> = {} const translatedEventCache: Map<string, Event> = new Map()
const translatedTextCache: Map<string, string> = new Map()
type TTranslationServiceContext = { type TTranslationServiceContext = {
config: TTranslationServiceConfig config: TTranslationServiceConfig
translatedEventIdSet: Set<string> translatedEventIdSet: Set<string>
translate: (event: Event) => Promise<Event | void> translateText: (text: string) => Promise<string>
translateEvent: (event: Event) => Promise<Event | void>
getTranslatedEvent: (eventId: string) => Event | null getTranslatedEvent: (eventId: string) => Event | null
showOriginalEvent: (eventId: string) => void showOriginalEvent: (eventId: string) => void
getAccount: () => Promise<TTranslationAccount | void> getAccount: () => Promise<TTranslationAccount | void>
@@ -62,38 +64,46 @@ export function TranslationServiceProvider({ children }: { children: React.React
const getTranslatedEvent = (eventId: string): Event | null => { const getTranslatedEvent = (eventId: string): Event | null => {
const target = i18n.language const target = i18n.language
const cacheKey = eventId + '_' + target const cacheKey = target + '_' + eventId
return translatedEventCache[cacheKey] ?? null return translatedEventCache.get(cacheKey) ?? null
} }
const translate = async (event: Event): Promise<Event | void> => { const translate = async (text: string, target: string): Promise<string> => {
if (config.service === 'jumble' && !pubkey) { if (config.service === 'jumble') {
startLogin() return await translation.translate(text, target)
return } else {
return await libreTranslate.translate(text, target, config.server, config.api_key)
}
}
const translateText = async (text: string): Promise<string> => {
if (!text) {
return text
} }
const target = i18n.language const target = i18n.language
const cacheKey = event.id + '_' + target const cacheKey = target + '_' + text
if (translatedEventCache[cacheKey]) { const cache = translatedTextCache.get(cacheKey)
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) if (cache) {
return translatedEventCache[cacheKey] return cache
} }
if (event.kind === kinds.Highlights) { const translatedText = await translate(text, target)
translatedTextCache.set(cacheKey, translatedText)
return translatedText
}
const translateHighlightEvent = async (event: Event): Promise<Event> => {
const target = i18n.language
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1] const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1]
if (!event.content && !comment) {
return event
}
const [translatedContent, translatedComment] = await Promise.all([ const [translatedContent, translatedComment] = await Promise.all([
config.service === 'jumble' translate(event.content, target),
? await translation.translate(event.content, target) !!comment && translate(comment, target)
: await libreTranslate.translate(event.content, target, config.server, config.api_key),
!!comment &&
(config.service === 'jumble'
? await translation.translate(comment, target)
: await libreTranslate.translate(comment, target, config.server, config.api_key))
]) ])
if (!translatedContent) {
return
}
const translatedEvent: Event = { const translatedEvent: Event = {
...event, ...event,
content: translatedContent content: translatedContent
@@ -103,20 +113,36 @@ export function TranslationServiceProvider({ children }: { children: React.React
tag[0] === 'comment' ? ['comment', translatedComment] : tag tag[0] === 'comment' ? ['comment', translatedComment] : tag
) )
} }
translatedEventCache[cacheKey] = translatedEvent
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent return translatedEvent
} }
const translatedText = const translateEvent = async (event: Event): Promise<Event | void> => {
config.service === 'jumble' if (config.service === 'jumble' && !pubkey) {
? await translation.translate(event.content, target) startLogin()
: await libreTranslate.translate(event.content, target, config.server, config.api_key) return
}
const target = i18n.language
const cacheKey = target + '_' + event.id
const cache = translatedEventCache.get(cacheKey)
if (cache) {
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return cache
}
let translatedEvent: Event | undefined
if (event.kind === kinds.Highlights) {
translatedEvent = await translateHighlightEvent(event)
} else {
const translatedText = await translate(event.content, target)
if (!translatedText) { if (!translatedText) {
return return
} }
const translatedEvent: Event = { ...event, content: translatedText } translatedEvent = { ...event, content: translatedText }
translatedEventCache[cacheKey] = translatedEvent }
translatedEventCache.set(cacheKey, translatedEvent)
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent return translatedEvent
} }
@@ -141,7 +167,8 @@ export function TranslationServiceProvider({ children }: { children: React.React
translatedEventIdSet, translatedEventIdSet,
getAccount, getAccount,
regenerateApiKey, regenerateApiKey,
translate, translateText,
translateEvent,
getTranslatedEvent, getTranslatedEvent,
showOriginalEvent, showOriginalEvent,
updateConfig updateConfig

View File

@@ -13,7 +13,10 @@ class LibreTranslateService {
target: string, target: string,
server?: string, server?: string,
api_key?: string api_key?: string
): Promise<string | undefined> { ): Promise<string> {
if (!text) {
return text
}
if (!server) { if (!server) {
throw new Error('LibreTranslate server address is not configured') throw new Error('LibreTranslate server address is not configured')
} }
@@ -27,7 +30,11 @@ class LibreTranslateService {
if (!response.ok) { if (!response.ok) {
throw new Error(data.error ?? 'Failed to translate') throw new Error(data.error ?? 'Failed to translate')
} }
return data.translatedText const translatedText = data.translatedText
if (!translatedText) {
throw new Error('Translation failed')
}
return translatedText
} }
} }

View File

@@ -60,14 +60,21 @@ class TranslationService {
} }
} }
async translate(text: string, target: string): Promise<string | undefined> { async translate(text: string, target: string): Promise<string> {
if (!text) {
return text
}
try { try {
const data = await this._fetch({ const data = await this._fetch({
path: '/v1/translation/translate', path: '/v1/translation/translate',
method: 'POST', method: 'POST',
body: JSON.stringify({ q: text, target }) body: JSON.stringify({ q: text, target })
}) })
return data.translatedText const translatedText = data.translatedText
if (!translatedText) {
throw new Error('Translation failed')
}
return translatedText
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : '' const errMsg = error instanceof Error ? error.message : ''
throw new Error(errMsg || 'Failed to translate') throw new Error(errMsg || 'Failed to translate')