Skip to content

Commit 947fa73

Browse files
committed
feat(translate): add MyMemory API cache and async translation
- Created TranslationCacheService with MyMemory API integration - Added TRANSLATION_CACHE (24h, 50000 entries) for persistent translation storage - Integrated MyMemory API as fallback before main translation service - Converted translateToAllLocales to async with CompletableFuture for parallel translations - Added useMyMemory config option (default: true) - Added MYMEMORY service type to translation services - Updated configs for minecraft and hytale platforms Benefits: - No chat delay: original message sent immediately, translations load in background - MyMemory provides free cached translations (10k requests/day) - Parallel translation requests instead of sequential - Local cache reduces API calls for repeated phrases
1 parent acd78e5 commit 947fa73

10 files changed

Lines changed: 235 additions & 28 deletions

File tree

core/src/main/java/net/flectone/pulse/config/Command.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,7 @@ public record Translateto(
765765
Boolean enable,
766766
Range range,
767767
Service service,
768+
Boolean useMyMemory,
768769
List<String> aliases,
769770
List<String> languages,
770771
Destination destination,
@@ -775,7 +776,8 @@ public record Translateto(
775776
public enum Service {
776777
DEEPL,
777778
GOOGLE,
778-
YANDEX
779+
YANDEX,
780+
MYMEMORY
779781
}
780782
}
781783

core/src/main/java/net/flectone/pulse/module/command/translateto/TranslatetoModule.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class TranslatetoModule implements ModuleCommand<Localization.Command.Tra
4646
private final MessageDispatcher messageDispatcher;
4747
private final ModuleController moduleController;
4848
private final ModuleCommandController commandModuleController;
49+
private final net.flectone.pulse.service.TranslationCacheService translationCacheService;
4950

5051
@Override
5152
public void onEnable() {
@@ -145,11 +146,34 @@ public Function<Localization.Command.Translateto, String> replaceLanguage(String
145146
}
146147

147148
public String translate(FPlayer fPlayer, String source, String target, String text) {
148-
return switch (config().service()) {
149+
// Check cache first if MyMemory is enabled
150+
if (config().useMyMemory() != null && config().useMyMemory()) {
151+
String cached = translationCacheService.get(source, target, text);
152+
if (cached != null) {
153+
return cached;
154+
}
155+
156+
// Try MyMemory API
157+
String myMemoryTranslation = translationCacheService.translateWithMyMemory(source, target, text);
158+
if (myMemoryTranslation != null && !myMemoryTranslation.isEmpty()) {
159+
return myMemoryTranslation;
160+
}
161+
}
162+
163+
// Fallback to configured service
164+
String translation = switch (config().service()) {
149165
case DEEPL -> integrationModule.deeplTranslate(fPlayer, source, target, text);
150166
case GOOGLE -> googleTranslate(source, target, text);
151167
case YANDEX -> integrationModule.yandexTranslate(fPlayer, source, target, text);
168+
case MYMEMORY -> translationCacheService.translateWithMyMemory(source, target, text);
152169
};
170+
171+
// Cache the result if MyMemory is enabled
172+
if (config().useMyMemory() != null && config().useMyMemory() && translation != null && !translation.isEmpty()) {
173+
translationCacheService.put(source, target, text, translation);
174+
}
175+
176+
return translation != null ? translation : "";
153177
}
154178

155179
public String googleTranslate(String source, String lang, String text) {

core/src/main/java/net/flectone/pulse/module/message/format/translate/TranslateModule.java

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import java.util.*;
3333
import java.util.concurrent.ConcurrentHashMap;
34+
import java.util.concurrent.CompletableFuture;
3435
import java.util.stream.Collectors;
3536

3637
@Singleton
@@ -45,6 +46,7 @@ public class TranslateModule implements ModuleLocalization<Localization.Message.
4546
private final ModuleController moduleController;
4647
private final FPlayerService fPlayerService;
4748
private final TranslatetoModule translatetoModule;
49+
private final net.flectone.pulse.service.TranslationCacheService translationCacheService;
4850

4951
@Override
5052
public void onEnable() {
@@ -112,8 +114,8 @@ public MessageContext addTag(MessageContext messageContext) {
112114
}
113115

114116
/**
115-
* Translate message to all unique locales on server.
116-
* Returns TranslatedMessage with all translations.
117+
* Translate message to all unique locales on server asynchronously.
118+
* Returns TranslatedMessage with original text immediately, translations are added asynchronously.
117119
*/
118120
public TranslatedMessage translateToAllLocales(String originalText, String sourceLang) {
119121
if (moduleController.isDisabledFor(this, FPlayer.UNKNOWN)) return null;
@@ -128,34 +130,44 @@ public TranslatedMessage translateToAllLocales(String originalText, String sourc
128130
uniqueLocales.add(sourceLang);
129131

130132
UUID messageUUID = UUID.randomUUID();
131-
TranslatedMessage.TranslatedMessageBuilder builder = TranslatedMessage.builder()
132-
.uuid(messageUUID)
133-
.originalText(originalText)
134-
.originalLang(sourceLang);
135-
136133
Map<String, Component> translations = new ConcurrentHashMap<>();
137134

138-
// Translate to each unique locale
139-
for (String targetLang : uniqueLocales) {
140-
Component translatedComponent;
141-
142-
if (targetLang.equals(sourceLang)) {
143-
// No translation needed for source language
144-
translatedComponent = Component.text(originalText);
145-
} else {
146-
// Translate using TranslatetoModule
147-
String translated = translatetoModule.translate(FPlayer.UNKNOWN, sourceLang, targetLang, originalText);
148-
translatedComponent = translated.isEmpty()
149-
? Component.text(originalText)
150-
: Component.text(translated);
151-
}
152-
153-
translations.put(targetLang, translatedComponent);
154-
}
135+
// Add original text immediately
136+
translations.put(sourceLang, Component.text(originalText));
137+
138+
TranslatedMessage translatedMessage = TranslatedMessage.builder()
139+
.uuid(messageUUID)
140+
.originalText(originalText)
141+
.originalLang(sourceLang)
142+
.translations(translations)
143+
.build();
155144

156-
TranslatedMessage translatedMessage = builder.translations(translations).build();
157145
translatedMessages.put(messageUUID, translatedMessage);
158146

147+
// Translate to each unique locale asynchronously in parallel
148+
List<CompletableFuture<Void>> futures = uniqueLocales.stream()
149+
.filter(targetLang -> !targetLang.equals(sourceLang))
150+
.map(targetLang -> translationCacheService.translateWithMyMemoryAsync(sourceLang, targetLang, originalText)
151+
.thenAccept(translated -> {
152+
Component translatedComponent = (translated != null && !translated.isEmpty())
153+
? Component.text(translated)
154+
: Component.text(originalText);
155+
translations.put(targetLang, translatedComponent);
156+
})
157+
.exceptionally(throwable -> {
158+
// On error, use original text
159+
translations.put(targetLang, Component.text(originalText));
160+
return null;
161+
})
162+
)
163+
.toList();
164+
165+
// When all translations complete, update the message (optional, for future use)
166+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
167+
.thenRun(() -> {
168+
// All translations completed, could trigger update here if needed
169+
});
170+
159171
return translatedMessage;
160172
}
161173

core/src/main/java/net/flectone/pulse/module/message/format/translate/listener/PulseAutoTranslateListener.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public void onMessagePrepareEvent(MessagePrepareEvent event) {
4242
String senderLocale = sender.getSetting(SettingText.LOCALE);
4343
if (senderLocale == null) senderLocale = "en_us";
4444

45-
// Translate to all unique locales on server
45+
// Translate to all unique locales on server asynchronously
46+
// Original text is available immediately, translations are added in background
4647
TranslatedMessage translatedMessage = translateModule.translateToAllLocales(message, senderLocale);
4748
if (translatedMessage != null) {
4849
// Store for later use in MessageSendEvent
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package net.flectone.pulse.service;
2+
3+
import com.google.common.cache.Cache;
4+
import com.google.inject.Inject;
5+
import com.google.inject.Singleton;
6+
import lombok.RequiredArgsConstructor;
7+
import net.flectone.pulse.platform.registry.CacheRegistry;
8+
import net.flectone.pulse.util.constant.CacheName;
9+
import org.jspecify.annotations.Nullable;
10+
11+
import java.io.BufferedReader;
12+
import java.io.InputStreamReader;
13+
import java.net.HttpURLConnection;
14+
import java.net.URI;
15+
import java.net.URLEncoder;
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.concurrent.CompletableFuture;
18+
import java.util.concurrent.ExecutorService;
19+
import java.util.concurrent.Executors;
20+
21+
@Singleton
22+
@RequiredArgsConstructor(onConstructor = @__(@Inject))
23+
public class TranslationCacheService {
24+
25+
private final CacheRegistry cacheRegistry;
26+
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
27+
28+
private Cache<String, String> getCache() {
29+
return cacheRegistry.getCache(CacheName.TRANSLATION_CACHE);
30+
}
31+
32+
/**
33+
* Generate cache key from source language, target language and text.
34+
*/
35+
private String getCacheKey(String sourceLang, String targetLang, String text) {
36+
return sourceLang + ":" + targetLang + ":" + text;
37+
}
38+
39+
/**
40+
* Get translation from cache.
41+
*/
42+
public @Nullable String get(String sourceLang, String targetLang, String text) {
43+
String key = getCacheKey(sourceLang, targetLang, text);
44+
return getCache().getIfPresent(key);
45+
}
46+
47+
/**
48+
* Put translation to cache.
49+
*/
50+
public void put(String sourceLang, String targetLang, String text, String translation) {
51+
String key = getCacheKey(sourceLang, targetLang, text);
52+
getCache().put(key, translation);
53+
}
54+
55+
/**
56+
* Translate text using MyMemory API.
57+
* Returns null if translation failed.
58+
*/
59+
public @Nullable String translateWithMyMemory(String sourceLang, String targetLang, String text) {
60+
try {
61+
// Normalize language codes (en_us -> en, ru_ru -> ru)
62+
String normalizedSource = normalizeLangCode(sourceLang);
63+
String normalizedTarget = normalizeLangCode(targetLang);
64+
65+
String encodedText = URLEncoder.encode(text, StandardCharsets.UTF_8);
66+
String urlString = "https://api.mymemory.translated.net/get?q=" + encodedText
67+
+ "&langpair=" + normalizedSource + "|" + normalizedTarget;
68+
69+
URI uri = new URI(urlString);
70+
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
71+
connection.setRequestMethod("GET");
72+
connection.setRequestProperty("User-Agent", "FlectonePulse/1.9.4");
73+
connection.setConnectTimeout(5000);
74+
connection.setReadTimeout(5000);
75+
76+
int responseCode = connection.getResponseCode();
77+
if (responseCode != 200) {
78+
return null;
79+
}
80+
81+
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
82+
StringBuilder response = new StringBuilder();
83+
String inputLine;
84+
85+
while ((inputLine = in.readLine()) != null) {
86+
response.append(inputLine);
87+
}
88+
in.close();
89+
90+
// Parse JSON response: {"responseData":{"translatedText":"..."}}
91+
String jsonResponse = response.toString();
92+
int startIndex = jsonResponse.indexOf("\"translatedText\":\"");
93+
if (startIndex == -1) {
94+
return null;
95+
}
96+
97+
startIndex += 18; // length of "translatedText":"
98+
int endIndex = jsonResponse.indexOf("\"", startIndex);
99+
if (endIndex == -1) {
100+
return null;
101+
}
102+
103+
String translation = jsonResponse.substring(startIndex, endIndex);
104+
105+
// Unescape JSON string
106+
translation = translation.replace("\\n", "\n")
107+
.replace("\\r", "\r")
108+
.replace("\\t", "\t")
109+
.replace("\\\"", "\"")
110+
.replace("\\\\", "\\");
111+
112+
return translation;
113+
} catch (Exception _) {
114+
return null;
115+
}
116+
}
117+
118+
/**
119+
* Translate text asynchronously using MyMemory API.
120+
*/
121+
public CompletableFuture<String> translateWithMyMemoryAsync(String sourceLang, String targetLang, String text) {
122+
return CompletableFuture.supplyAsync(() -> {
123+
String translation = translateWithMyMemory(sourceLang, targetLang, text);
124+
if (translation != null && !translation.isEmpty()) {
125+
put(sourceLang, targetLang, text, translation);
126+
}
127+
return translation;
128+
}, executorService);
129+
}
130+
131+
/**
132+
* Normalize language code from minecraft format to ISO 639-1.
133+
* Examples: en_us -> en, ru_ru -> ru, zh_cn -> zh
134+
*/
135+
private String normalizeLangCode(String langCode) {
136+
if (langCode == null || langCode.isEmpty()) {
137+
return "en";
138+
}
139+
140+
// Extract first part before underscore
141+
int underscoreIndex = langCode.indexOf('_');
142+
if (underscoreIndex > 0) {
143+
return langCode.substring(0, underscoreIndex).toLowerCase();
144+
}
145+
146+
return langCode.toLowerCase();
147+
}
148+
149+
/**
150+
* Shutdown executor service.
151+
*/
152+
public void shutdown() {
153+
executorService.shutdown();
154+
}
155+
}

core/src/main/java/net/flectone/pulse/util/constant/CacheName.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public enum CacheName {
1313
REPLACEMENT_MESSAGE,
1414
REPLACEMENT_IMAGE,
1515
TRANSLATE_MESSAGE,
16+
TRANSLATION_CACHE,
1617
PROFILE_PROPERTY
1718

1819
}

hytale/src/main/resources/config/hytale/command.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ translateto:
780780
enable: true
781781
range: "PLAYER"
782782
service: "GOOGLE"
783+
use_my_memory: true
783784
aliases:
784785
- "translateto"
785786
languages:

hytale/src/main/resources/config/hytale/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ cache:
123123
duration: 1
124124
time_unit: "HOURS"
125125
size: 5000
126+
TRANSLATION_CACHE:
127+
invalidate_on_reload: false
128+
duration: 24
129+
time_unit: "HOURS"
130+
size: 50000
126131
PROFILE_PROPERTY:
127132
invalidate_on_reload: false
128133
duration: 1

minecraft/common/src/main/resources/config/minecraft/command.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ translateto:
780780
enable: true
781781
range: "PLAYER"
782782
service: "GOOGLE"
783+
use_my_memory: true
783784
aliases:
784785
- "translateto"
785786
languages:

minecraft/common/src/main/resources/config/minecraft/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ cache:
123123
duration: 1
124124
time_unit: "HOURS"
125125
size: 5000
126+
TRANSLATION_CACHE:
127+
invalidate_on_reload: false
128+
duration: 24
129+
time_unit: "HOURS"
130+
size: 50000
126131
PROFILE_PROPERTY:
127132
invalidate_on_reload: false
128133
duration: 1

0 commit comments

Comments
 (0)