import { GetConfiguration, GetLocalizationManager, GetSessionDataManager, TranslationLanguagesEvent, TranslationLanguagesRequestComposer, TranslationResultEvent, TranslationTextRequestComposer } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { LocalStorageKeys, SendMessageComposer } from '../../api'; import { useMessageEvent } from '../events'; import { useLocalStorage } from '../useLocalStorage'; const REQUEST_TIMEOUT_MS = 8000; const OUTGOING_QUEUE_TTL_MS = 30000; export interface ITranslationSettings { enabled: boolean; incomingTargetLanguage: string; outgoingTargetLanguage: string; uiTextLanguage: string; } export interface ITranslationLanguage { code: string; name: string; } export interface ITranslationTextLocale extends ITranslationLanguage { file: string; } export interface IResolvedTranslation { originalText: string; translatedText: string; detectedLanguage: string; targetLanguage: string; } interface IPendingTranslationRequest { resolve: (translation: IResolvedTranslation) => void; reject: (error: Error) => void; timeoutId: number; } interface IQueuedOutgoingTranslation extends IResolvedTranslation { expiresAt: number; } const normalizeLanguageCode = (value: string) => { if(!value || !value.trim().length) return ''; const normalized = value.trim().replace('_', '-'); const parts = normalized.split('-'); if(parts.length === 1) return parts[0].toLowerCase(); return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`; }; const TEXT_TRANSLATION_LOCALES: ITranslationTextLocale[] = [ { code: 'pt-BR', name: 'Portuguese (Brazil)', file: 'br' }, { code: 'en', name: 'English', file: 'com' }, { code: 'de', name: 'German', file: 'de' }, { code: 'es', name: 'Spanish', file: 'es' }, { code: 'fi', name: 'Finnish', file: 'fi' }, { code: 'fr', name: 'French', file: 'fr' }, { code: 'it', name: 'Italian', file: 'it' }, { code: 'nl', name: 'Dutch', file: 'nl' }, { code: 'tr', name: 'Turkish', file: 'tr' } ]; const resolveTextTranslationLocale = (value: string) => { const normalizedValue = normalizeLanguageCode(value); if(!normalizedValue.length) return null; const exactMatch = TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code) === normalizedValue)); if(exactMatch) return exactMatch; const normalizedBase = normalizedValue.split('-')[0]; if(normalizedBase === 'pt') return TEXT_TRANSLATION_LOCALES.find(locale => (locale.file === 'br')) || null; return TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code).split('-')[0] === normalizedBase)) || null; }; const interpolateTranslationUrl = (template: string, file: string) => { if(!template || !template.length) return ''; return GetConfiguration().interpolate( template .replace(/%locale%/gi, file) .replace(/%timestamp%/gi, Date.now().toString())); }; const getTextTranslationUrl = (file: string) => { const configuredTranslationUrl = GetConfiguration().getValue('external.texts.translation.url') || ''; if(configuredTranslationUrl.length) { return interpolateTranslationUrl(configuredTranslationUrl, file); } const externalTextUrls = GetConfiguration().getValue('external.texts.url') || []; const externalTextsUrl = externalTextUrls.length ? GetConfiguration().interpolate(externalTextUrls[0]) : ''; if(!externalTextsUrl.length) return `/text_translate/ExternalTexts_${ file }.json`; const lastSlashIndex = externalTextsUrl.lastIndexOf('/'); if(lastSlashIndex === -1) return `text_translate/ExternalTexts_${ file }.json`; const basePath = externalTextsUrl.substring(0, lastSlashIndex); return `${ basePath }/text_translate/ExternalTexts_${ file }.json`; }; const getFurnitureTranslationUrl = (file: string) => { const configuredTranslationUrl = GetConfiguration().getValue('furnidata.translation.url') || ''; if(configuredTranslationUrl.length) { return interpolateTranslationUrl(configuredTranslationUrl, file); } const furnidataUrl = GetConfiguration().interpolate(GetConfiguration().getValue('furnidata.url') || ''); if(!furnidataUrl.length) return `/furniture_translate/FurnitureData_${ file }.json`; const lastSlashIndex = furnidataUrl.lastIndexOf('/'); if(lastSlashIndex === -1) return `furniture_translate/FurnitureData_${ file }.json`; const basePath = furnidataUrl.substring(0, lastSlashIndex); return `${ basePath }/furniture_translate/FurnitureData_${ file }.json`; }; const dispatchLocalizationUpdated = () => { if(typeof window === 'undefined') return; window.dispatchEvent(new CustomEvent('nitro-localization-updated')); }; export const applyTextTranslationLocale = async (languageCode: string): Promise => { const localizationManager = GetLocalizationManager(); const sessionDataManager = GetSessionDataManager(); const selectedLocale = resolveTextTranslationLocale(languageCode || ''); if(!selectedLocale) { localizationManager.clearOverrideValues(); sessionDataManager.clearFurnitureDataOverrides(); dispatchLocalizationUpdated(); return; } const textUrl = getTextTranslationUrl(selectedLocale.file); const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); const response = await fetch(textUrl); if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); const data = await response.json(); const overrideValues = new Map(); Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); localizationManager.setOverrideValues(overrideValues); try { await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); } catch { sessionDataManager.clearFurnitureDataOverrides(); } dispatchLocalizationUpdated(); }; const getBrowserLanguageCode = () => { if(typeof navigator === 'undefined') return 'en'; return normalizeLanguageCode(navigator.language || 'en').split('-')[0] || 'en'; }; const decodeHtmlEntities = (value: string) => { if(!value || (typeof window === 'undefined')) return value; const textarea = document.createElement('textarea'); textarea.innerHTML = value; return textarea.value; }; const resolveSupportedLanguage = (value: string, languages: ITranslationLanguage[]) => { const normalizedValue = normalizeLanguageCode(value); if(!languages.length) return normalizedValue || 'en'; const exactMatch = languages.find(language => (normalizeLanguageCode(language.code) === normalizedValue)); if(exactMatch) return exactMatch.code; const normalizedBase = normalizedValue.split('-')[0]; const baseMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase)); if(baseMatch) return baseMatch.code; const englishMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === 'en')); if(englishMatch) return englishMatch.code; return languages[0].code; }; const useTranslationStore = () => { const defaultTargetLanguage = getBrowserLanguageCode(); const [ settings, setSettings ] = useLocalStorage(LocalStorageKeys.CHAT_TRANSLATION_SETTINGS, { enabled: false, incomingTargetLanguage: defaultTargetLanguage, outgoingTargetLanguage: defaultTargetLanguage, uiTextLanguage: '' }); const [ supportedLanguages, setSupportedLanguages ] = useState([]); const [ availableTextLocales ] = useState(TEXT_TRANSLATION_LOCALES); const [ languagesLoading, setLanguagesLoading ] = useState(false); const [ languagesLoaded, setLanguagesLoaded ] = useState(false); const [ localizationTextsLoading, setLocalizationTextsLoading ] = useState(false); const [ lastIncomingLanguage, setLastIncomingLanguage ] = useState(''); const [ lastOutgoingLanguage, setLastOutgoingLanguage ] = useState(''); const [ lastError, setLastError ] = useState(''); const requestIdRef = useRef(0); const languagesTimeoutRef = useRef(0); const pendingRequestsRef = useRef(new Map()); const translationCacheRef = useRef(new Map()); const outgoingQueueRef = useRef(new Map()); const localizationRequestRef = useRef(0); const clearLanguagesTimeout = useCallback(() => { if(!languagesTimeoutRef.current) return; window.clearTimeout(languagesTimeoutRef.current); languagesTimeoutRef.current = 0; }, []); const pruneOutgoingQueue = useCallback(() => { const now = Date.now(); outgoingQueueRef.current.forEach((entries, key) => { const activeEntries = entries.filter(entry => (entry.expiresAt > now)); if(activeEntries.length) { outgoingQueueRef.current.set(key, activeEntries); return; } outgoingQueueRef.current.delete(key); }); }, []); const updateSettings = useCallback((partial: Partial) => { setSettings(prevValue => ({ ...prevValue, ...partial })); }, [ setSettings ]); const getLanguageName = useCallback((languageCode: string) => { const normalizedLanguageCode = normalizeLanguageCode(languageCode); if(!normalizedLanguageCode.length) return 'auto'; const exactMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code) === normalizedLanguageCode)); if(exactMatch) return exactMatch.name; const normalizedBase = normalizedLanguageCode.split('-')[0]; const baseMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase)); return baseMatch?.name || normalizedLanguageCode; }, [ supportedLanguages ]); const handleLanguagesEvent = useCallback((event: TranslationLanguagesEvent) => { const parser = event.getParser(); clearLanguagesTimeout(); setLanguagesLoading(false); if(!parser.success) { setLanguagesLoaded(false); setLastError(parser.errorMessage || 'Unable to load Google Translate languages.'); return; } const nextLanguages = parser.languages.map(language => ({ code: normalizeLanguageCode(language.code), name: language.name })); setSupportedLanguages(nextLanguages); setLanguagesLoaded(true); setLastError(''); }, [ clearLanguagesTimeout ]); const handleTranslationResult = useCallback((event: TranslationResultEvent) => { const parser = event.getParser(); const pendingRequest = pendingRequestsRef.current.get(parser.requestId); if(!pendingRequest) return; window.clearTimeout(pendingRequest.timeoutId); pendingRequestsRef.current.delete(parser.requestId); if(!parser.success) { pendingRequest.reject(new Error(parser.errorMessage || 'Unable to translate text.')); return; } pendingRequest.resolve({ originalText: decodeHtmlEntities(parser.originalText || ''), translatedText: decodeHtmlEntities(parser.translatedText || ''), detectedLanguage: normalizeLanguageCode(parser.detectedLanguage || ''), targetLanguage: normalizeLanguageCode(parser.targetLanguage || '') }); }, []); useMessageEvent(TranslationLanguagesEvent, handleLanguagesEvent); useMessageEvent(TranslationResultEvent, handleTranslationResult); const ensureSupportedLanguagesLoaded = useCallback((force: boolean = false) => { if(languagesLoading) return; if(languagesLoaded && !force) return; setLanguagesLoading(true); setLastError(''); clearLanguagesTimeout(); languagesTimeoutRef.current = window.setTimeout(() => { setLanguagesLoading(false); setLastError('Google Translate did not respond while loading languages.'); }, REQUEST_TIMEOUT_MS); SendMessageComposer(new TranslationLanguagesRequestComposer(getBrowserLanguageCode())); }, [ clearLanguagesTimeout, languagesLoaded, languagesLoading ]); const translateText = useCallback((text: string, targetLanguage: string) => { const safeText = (text || ''); const normalizedTargetLanguage = normalizeLanguageCode(targetLanguage || defaultTargetLanguage) || defaultTargetLanguage; if(!safeText.trim().length) { return Promise.resolve({ originalText: safeText, translatedText: safeText, detectedLanguage: '', targetLanguage: normalizedTargetLanguage }); } const cacheKey = `${ normalizedTargetLanguage }\u0000${ safeText }`; const cachedValue = translationCacheRef.current.get(cacheKey); if(cachedValue) return Promise.resolve(cachedValue); return new Promise((resolve, reject) => { const requestId = ++requestIdRef.current; const timeoutId = window.setTimeout(() => { pendingRequestsRef.current.delete(requestId); reject(new Error('Google Translate did not respond in time.')); }, REQUEST_TIMEOUT_MS); pendingRequestsRef.current.set(requestId, { resolve, reject, timeoutId }); SendMessageComposer(new TranslationTextRequestComposer(requestId, safeText, normalizedTargetLanguage)); }).then(result => { translationCacheRef.current.set(cacheKey, result); return result; }); }, [ defaultTargetLanguage ]); const translateIncoming = useCallback(async (text: string) => { if(!settings.enabled) return null; try { const result = await translateText(text, settings.incomingTargetLanguage || defaultTargetLanguage); setLastIncomingLanguage(result.detectedLanguage || ''); setLastError(''); return result; } catch(error) { setLastError((error as Error)?.message || 'Unable to translate incoming text.'); return null; } }, [ defaultTargetLanguage, settings.enabled, settings.incomingTargetLanguage, translateText ]); const translateOutgoing = useCallback(async (text: string) => { if(!settings.enabled) return null; try { const result = await translateText(text, settings.outgoingTargetLanguage || defaultTargetLanguage); setLastOutgoingLanguage(result.detectedLanguage || ''); setLastError(''); return result; } catch(error) { setLastError((error as Error)?.message || 'Unable to translate outgoing text.'); return null; } }, [ defaultTargetLanguage, settings.enabled, settings.outgoingTargetLanguage, translateText ]); const enqueueOutgoingTranslation = useCallback((translation: IResolvedTranslation) => { if(!translation) return; pruneOutgoingQueue(); const queueKey = translation.translatedText || translation.originalText; const currentEntries = outgoingQueueRef.current.get(queueKey) || []; currentEntries.push({ ...translation, expiresAt: (Date.now() + OUTGOING_QUEUE_TTL_MS) }); outgoingQueueRef.current.set(queueKey, currentEntries); setLastOutgoingLanguage(translation.detectedLanguage || ''); }, [ pruneOutgoingQueue ]); const consumeOutgoingTranslation = useCallback((translatedText: string) => { pruneOutgoingQueue(); const queueKey = translatedText || ''; const currentEntries = outgoingQueueRef.current.get(queueKey); if(!currentEntries?.length) return null; const entry = currentEntries.shift(); if(currentEntries.length) outgoingQueueRef.current.set(queueKey, currentEntries); else outgoingQueueRef.current.delete(queueKey); if(entry?.detectedLanguage) setLastOutgoingLanguage(entry.detectedLanguage); return entry || null; }, [ pruneOutgoingQueue ]); useEffect(() => { if(!settings.enabled) return; ensureSupportedLanguagesLoaded(); }, [ ensureSupportedLanguagesLoaded, settings.enabled ]); useEffect(() => { if(!supportedLanguages.length) return; const resolvedIncomingTargetLanguage = resolveSupportedLanguage(settings.incomingTargetLanguage || defaultTargetLanguage, supportedLanguages); const resolvedOutgoingTargetLanguage = resolveSupportedLanguage(settings.outgoingTargetLanguage || defaultTargetLanguage, supportedLanguages); if((resolvedIncomingTargetLanguage === settings.incomingTargetLanguage) && (resolvedOutgoingTargetLanguage === settings.outgoingTargetLanguage)) return; setSettings(prevValue => ({ ...prevValue, incomingTargetLanguage: resolvedIncomingTargetLanguage, outgoingTargetLanguage: resolvedOutgoingTargetLanguage })); }, [ defaultTargetLanguage, setSettings, settings.incomingTargetLanguage, settings.outgoingTargetLanguage, supportedLanguages ]); useEffect(() => { let disposed = false; const requestId = ++localizationRequestRef.current; const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || ''); const applyLocalizationOverride = async () => { if(!selectedLocale) { await applyTextTranslationLocale(''); if((localizationRequestRef.current === requestId) && !disposed) { setLocalizationTextsLoading(false); setLastError(''); } return; } if(!disposed) setLocalizationTextsLoading(true); try { if(disposed || (localizationRequestRef.current !== requestId)) return; await applyTextTranslationLocale(settings.uiTextLanguage || ''); if(disposed || (localizationRequestRef.current !== requestId)) return; setLastError(''); } catch(error) { if(disposed || (localizationRequestRef.current !== requestId)) return; await applyTextTranslationLocale(''); setLastError((error as Error)?.message || 'Unable to load translated UI texts.'); } finally { if(disposed || (localizationRequestRef.current !== requestId)) return; setLocalizationTextsLoading(false); } }; applyLocalizationOverride(); return () => { disposed = true; }; }, [ settings.uiTextLanguage ]); useEffect(() => { return () => { clearLanguagesTimeout(); pendingRequestsRef.current.forEach(pendingRequest => window.clearTimeout(pendingRequest.timeoutId)); pendingRequestsRef.current.clear(); outgoingQueueRef.current.clear(); }; }, [ clearLanguagesTimeout ]); return { settings, supportedLanguages, availableTextLocales, languagesLoading, languagesLoaded, localizationTextsLoading, lastIncomingLanguage, lastOutgoingLanguage, lastError, updateSettings, ensureSupportedLanguagesLoaded, translateText, translateIncoming, translateOutgoing, enqueueOutgoingTranslation, consumeOutgoingTranslation, getLanguageName }; }; export const useTranslationState = () => { const { settings, supportedLanguages, availableTextLocales, languagesLoading, languagesLoaded, localizationTextsLoading, lastIncomingLanguage, lastOutgoingLanguage, lastError, getLanguageName } = useBetween(useTranslationStore); return { settings, supportedLanguages, availableTextLocales, languagesLoading, languagesLoaded, localizationTextsLoading, lastIncomingLanguage, lastOutgoingLanguage, lastError, getLanguageName }; }; export const useTranslationActions = () => { const { settings, updateSettings, ensureSupportedLanguagesLoaded, translateText, translateIncoming, translateOutgoing, enqueueOutgoingTranslation, consumeOutgoingTranslation } = useBetween(useTranslationStore); return { settings, updateSettings, ensureSupportedLanguagesLoaded, translateText, translateIncoming, translateOutgoing, enqueueOutgoingTranslation, consumeOutgoingTranslation }; }; export const useTranslation = () => useBetween(useTranslationStore);