Browse Source

feat: support translation for profile abount

imwald
codytseng 9 months ago
parent
commit
d3093a1c4e
  1. 2
      src/components/Nip05/index.tsx
  2. 75
      src/components/ProfileAbout/index.tsx
  3. 76
      src/components/TranslateButton/index.tsx
  4. 5
      src/i18n/locales/ar.ts
  5. 5
      src/i18n/locales/de.ts
  6. 5
      src/i18n/locales/en.ts
  7. 5
      src/i18n/locales/es.ts
  8. 5
      src/i18n/locales/fr.ts
  9. 5
      src/i18n/locales/it.ts
  10. 5
      src/i18n/locales/ja.ts
  11. 5
      src/i18n/locales/pl.ts
  12. 5
      src/i18n/locales/pt-BR.ts
  13. 5
      src/i18n/locales/pt-PT.ts
  14. 5
      src/i18n/locales/ru.ts
  15. 5
      src/i18n/locales/th.ts
  16. 5
      src/i18n/locales/zh.ts
  17. 72
      src/lib/utils.ts
  18. 111
      src/providers/TranslationServiceProvider.tsx
  19. 11
      src/services/libre-translate.service.ts
  20. 11
      src/services/translation.service.ts

2
src/components/Nip05/index.tsx

@ -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>

75
src/components/ProfileAbout/index.tsx

@ -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>
)
} }

76
src/components/TranslateButton/index.tsx

@ -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')

5
src/i18n/locales/ar.ts

@ -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': 'عرض الأصل'
} }
} }

5
src/i18n/locales/de.ts

@ -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'
} }
} }

5
src/i18n/locales/en.ts

@ -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'
} }
} }

5
src/i18n/locales/es.ts

@ -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'
} }
} }

5
src/i18n/locales/fr.ts

@ -280,6 +280,9 @@ export default {
'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes qu’elles suivent.', 'Les utilisateurs de confiance incluent les personnes que vous suivez et les personnes qu’elles 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 l’original'
} }
} }

5
src/i18n/locales/it.ts

@ -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'
} }
} }

5
src/i18n/locales/ja.ts

@ -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': '原文を表示'
} }
} }

5
src/i18n/locales/pl.ts

@ -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ł'
} }
} }

5
src/i18n/locales/pt-BR.ts

@ -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'
} }
} }

5
src/i18n/locales/pt-PT.ts

@ -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'
} }
} }

5
src/i18n/locales/ru.ts

@ -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': 'Показать оригинал'
} }
} }

5
src/i18n/locales/th.ts

@ -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': 'แสดงตนฉบบ'
} }
} }

5
src/i18n/locales/zh.ts

@ -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': '显示原文'
} }
} }

72
src/lib/utils.ts

@ -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'
}
}

111
src/providers/TranslationServiceProvider.tsx

@ -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,61 +64,85 @@ 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') {
return await translation.translate(text, target)
} 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 cacheKey = target + '_' + text
const cache = translatedTextCache.get(cacheKey)
if (cache) {
return cache
}
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]
if (!event.content && !comment) {
return event
}
const [translatedContent, translatedComment] = await Promise.all([
translate(event.content, target),
!!comment && translate(comment, target)
])
const translatedEvent: Event = {
...event,
content: translatedContent
}
if (translatedComment) {
translatedEvent.tags = event.tags.map((tag) =>
tag[0] === 'comment' ? ['comment', translatedComment] : tag
)
}
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent
}
const translateEvent = async (event: Event): Promise<Event | void> => {
if (config.service === 'jumble' && !pubkey) { if (config.service === 'jumble' && !pubkey) {
startLogin() startLogin()
return return
} }
const target = i18n.language const target = i18n.language
const cacheKey = event.id + '_' + target const cacheKey = target + '_' + event.id
if (translatedEventCache[cacheKey]) { const cache = translatedEventCache.get(cacheKey)
if (cache) {
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id))) setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEventCache[cacheKey] return cache
} }
let translatedEvent: Event | undefined
if (event.kind === kinds.Highlights) { if (event.kind === kinds.Highlights) {
const comment = event.tags.find((tag) => tag[0] === 'comment')?.[1] translatedEvent = await translateHighlightEvent(event)
const [translatedContent, translatedComment] = await Promise.all([ } else {
config.service === 'jumble' const translatedText = await translate(event.content, target)
? await translation.translate(event.content, target) if (!translatedText) {
: 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 return
} }
const translatedEvent: Event = { translatedEvent = { ...event, content: translatedText }
...event,
content: translatedContent
}
if (translatedComment) {
translatedEvent.tags = event.tags.map((tag) =>
tag[0] === 'comment' ? ['comment', translatedComment] : tag
)
}
translatedEventCache[cacheKey] = translatedEvent
setTranslatedEventIdSet((prev) => new Set(prev.add(event.id)))
return translatedEvent
} }
const translatedText = translatedEventCache.set(cacheKey, translatedEvent)
config.service === 'jumble'
? await translation.translate(event.content, target)
: await libreTranslate.translate(event.content, target, config.server, config.api_key)
if (!translatedText) {
return
}
const translatedEvent: Event = { ...event, content: translatedText }
translatedEventCache[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

11
src/services/libre-translate.service.ts

@ -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
} }
} }

11
src/services/translation.service.ts

@ -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')

Loading…
Cancel
Save