mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2024-11-22 04:05:39 +00:00
GP-4877 add LibreTranslate string translation service provider
This allows the user to translate strings in a program using an external LibreTranslate server.
This commit is contained in:
parent
9d641ed2da
commit
5738a6c2df
@ -381,6 +381,7 @@ src/main/help/help/topics/LanguageProviderPlugin/Languages.htm||GHIDRA||||END|
|
||||
src/main/help/help/topics/LanguageProviderPlugin/images/Languages.png||GHIDRA||||END|
|
||||
src/main/help/help/topics/LanguageProviderPlugin/images/PCodeDisplay.png||GHIDRA||||END|
|
||||
src/main/help/help/topics/LanguageProviderPlugin/images/Warning.png||GHIDRA||||END|
|
||||
src/main/help/help/topics/LibreTranslatePlugin/LibreTranslatePlugin.htm||GHIDRA||||END|
|
||||
src/main/help/help/topics/LocationReferencesPlugin/Location_References.html||GHIDRA||||END|
|
||||
src/main/help/help/topics/LocationReferencesPlugin/images/LabelReferencesSample.png||GHIDRA||||END|
|
||||
src/main/help/help/topics/LocationReferencesPlugin/images/ReferencesToDialog.png||GHIDRA||||END|
|
||||
|
@ -230,7 +230,9 @@
|
||||
<tocdef id="Data" sortgroup="c" text="Data" target="help/topics/DataPlugin/Data.htm" >
|
||||
<tocdef id="Create Data" sortgroup="a" text="Create Data" target="help/topics/DataPlugin/Data.htm" />
|
||||
<tocdef id="Data Types" sortgroup="b" text="Data Types" target="help/topics/DataPlugin/Data.htm#DataTypes" />
|
||||
<tocdef id="Translate Strings" sortgroup="e" text="Translate Strings" target="help/topics/TranslateStringsPlugin/TranslateStringsPlugin.htm" />
|
||||
<tocdef id="Translate Strings" sortgroup="e" text="Translate Strings" target="help/topics/TranslateStringsPlugin/TranslateStringsPlugin.htm">
|
||||
<tocdef id="LibreTranslate" sortgroup="e" text="LibreTranslate" target="help/topics/LibreTranslatePlugin/LibreTranslatePlugin.htm" />
|
||||
</tocdef>
|
||||
<tocdef id="Save Image" sortgroup="e" text="Save Image" target="help/topics/ResourceActionsPlugin/ResourceActions.html" />
|
||||
</tocdef>
|
||||
|
||||
|
@ -0,0 +1,78 @@
|
||||
<!DOCTYPE doctype PUBLIC "-//W3C//DTD HTML 4.0 Frameset//EN">
|
||||
|
||||
<HTML>
|
||||
<HEAD>
|
||||
<META name="generator" content=
|
||||
"HTML Tidy for Java (vers. 2009-12-01), see jtidy.sourceforge.net">
|
||||
|
||||
<TITLE>LibreTranslate Plugin</TITLE>
|
||||
<META http-equiv="Content-Type" content="text/html; charset=windows-1252">
|
||||
<LINK rel="stylesheet" type="text/css" href="help/shared/DefaultStyle.css">
|
||||
</HEAD>
|
||||
|
||||
<BODY lang="EN-US">
|
||||
<H1><a name="LibreTranslatePlugin"></a>LibreTranslate Plugin</H1>
|
||||
|
||||
<P>This plugin adds a string translation service that will appear in the <b>Translate</b>
|
||||
menu of a string data instance. The <b>Translate</b> menu will appear in the right-click
|
||||
context menu of data items that are strings.</P>
|
||||
|
||||
<P>LibreTranslate (currently hosted at libretranslate.com) is an independant project that
|
||||
provides an open source translation package that can be self-hosted.</P>
|
||||
|
||||
<P>This plugin queries a LibreTranslate server via HTTP to translate each specified string into
|
||||
a target language. The results of that translation will be determined by the LibreTranslate
|
||||
server.</P>
|
||||
|
||||
<P>A LibreTranslate server can be installed locally by following the instructions provided
|
||||
on LibreTranslate's website, and then this plugin can connect to it via a URL such as
|
||||
<b>http://localhost:5000/</b> (when configured with suggested defaults).</P>
|
||||
|
||||
<P>It is also possible to use someone else's LibreTranslate server, and typically they
|
||||
will issue an API key that will authorize the user to connect.</P>
|
||||
|
||||
<P>When a string has been translated, the translated value will be shown in place of
|
||||
the original value, bracketed with <b>»chevrons«</b></P>
|
||||
|
||||
<h2><a name="Configuration"></a>Configuration</h2>
|
||||
<P>See
|
||||
<b>Edit <IMG src="help/shared/arrow.gif" alt="->" border="0">
|
||||
Tool Options <IMG src="help/shared/arrow.gif" alt="->" border="0">
|
||||
Strings | LibreTranslate</b>
|
||||
</P>
|
||||
<blockquote>
|
||||
<UL>
|
||||
<LI><b>URL</b> - required. Example: <b>http://localhost:5000/</b>
|
||||
(if self hosted and following suggested values)</LI>
|
||||
<LI><b>API Key</b> - a unique key that authorizes you to connect to the LibreTranslate
|
||||
server. Can be blank if api keys are not required.</LI>
|
||||
<LI><b>Source Language</b> - either "auto" or "prompt"</LI>
|
||||
<LI><b>Target Language</b> - the language code (as defined by LibreTranslate) that
|
||||
strings should be translated into. This defaults to "en" (English).</LI>
|
||||
<LI><b>Batch Size</b> - the maximum number of strings to include in a single request
|
||||
to the LibreTranslate server.</LI>
|
||||
<LI><b>HTTP Timeout</b> - the maximum number of milliseconds to wait for the
|
||||
LibreTranslate HTTP server to respond to a request.</LI>
|
||||
<LI><b>HTTP Timeout [per string]</b> - an additional number of milliseconds,
|
||||
per string in each request, to wait for the LibreTranslate HTTP server to
|
||||
respond.</LI>
|
||||
</UL>
|
||||
</blockquote>
|
||||
|
||||
|
||||
<P class="providedbyplugin">Provided by: <I>LibreTranslate Plugin</I></P>
|
||||
|
||||
<P class="relatedtopic">Related Topics:</P>
|
||||
<UL>
|
||||
<LI><P class="relatedtopic"><A href="help/topics/CodeBrowserPlugin/CodeBrowser.htm">Code Browser</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="help/topics/ViewStringsPlugin/ViewStringsPlugin.htm">View Defined Strings</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="help/topics/TranslateStringsPlugin/TranslateStringsPlugin.htm">Translate Strings Plugin</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="help/topics/Search/Search_for_Strings.htm#Encoded_Strings_Dialog">Search For Encoded Strings</A></P></LI>
|
||||
</UL>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</BODY>
|
||||
</HTML>
|
@ -348,13 +348,11 @@
|
||||
<P class="relatedtopic">Related Topics:</P>
|
||||
|
||||
<UL>
|
||||
<LI><A href="Search_Memory.htm">Search Memory</A></LI>
|
||||
|
||||
<LI><A href="Search_Memory.htm">Search Program Memory</A></LI>
|
||||
|
||||
<LI><A href="Search_Program_Text.htm">Search Program Text</A></LI>
|
||||
|
||||
<LI><A href="Searching.htm">Searching</A></LI>
|
||||
<LI><P class="relatedtopic"><A href="Search_Memory.htm">Search Memory</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="Search_Memory.htm">Search Program Memory</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="Search_Program_Text.htm">Search Program Text</A></P></LI>
|
||||
<LI><P class="relatedtopic"><A href="Searching.htm">Searching</A></P></LI>
|
||||
</UL>
|
||||
<br>
|
||||
</BODY>
|
||||
</HTML>
|
||||
|
@ -0,0 +1,187 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package ghidra.app.plugin.core.string.translate.libretranslate;
|
||||
|
||||
import static ghidra.framework.options.OptionType.*;
|
||||
|
||||
import java.net.*;
|
||||
import java.util.List;
|
||||
|
||||
import docking.options.OptionsService;
|
||||
import ghidra.MiscellaneousPluginPackage;
|
||||
import ghidra.app.plugin.PluginCategoryNames;
|
||||
import ghidra.app.services.StringTranslationService;
|
||||
import ghidra.framework.options.*;
|
||||
import ghidra.framework.plugintool.*;
|
||||
import ghidra.framework.plugintool.util.PluginStatus;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.util.ProgramLocation;
|
||||
import ghidra.util.*;
|
||||
|
||||
//@formatter:off
|
||||
@PluginInfo(
|
||||
status = PluginStatus.RELEASED,
|
||||
packageName = MiscellaneousPluginPackage.NAME,
|
||||
category = PluginCategoryNames.COMMON,
|
||||
shortDescription = "String translations using LibreTranslate",
|
||||
description =
|
||||
"Uses an external LibreTranslate server to translate strings.\n" +
|
||||
"See LibreTranslate's website for links to their docs about\n" +
|
||||
"API keys or instructions for self-hosting your own instance.",
|
||||
servicesProvided = { StringTranslationService.class }
|
||||
)
|
||||
//@formatter:on
|
||||
public class LibreTranslatePlugin extends Plugin implements OptionsChangeListener {
|
||||
public static final String LIBRE_TRANSLATE_SERVICE_NAME = "LibreTranslate";
|
||||
|
||||
private static final String STRINGS_OPTION = "Strings";
|
||||
private static final String LT_OPTION = "LibreTranslate";
|
||||
private static final String SOURCE_LANG_OPTION = "Source Language";
|
||||
private static final String API_KEY_OPTION = "API Key";
|
||||
private static final String URL_OPTION = "URL";
|
||||
private static final String TARGET_LANG_OPTION = "Target Language";
|
||||
private static final String BATCHSIZE_OPTION = "Batch Size";
|
||||
private static final String HTTP_TIMEOUT_OPTION = "HTTP Timeout";
|
||||
private static final String HTTP_TIMEOUT_PER_ELEMENT_OPTION = "HTTP Timeout [per string]";
|
||||
|
||||
private static final int BATCHSIZE_DEFAULT = 50;
|
||||
private static final int BATCHSIZE_MAX = 1000;
|
||||
private static final int TIMEOUT_DEFAULT = 20 * 1000; // 20 seconds
|
||||
private static final int TIMEOUT_PERSTRING_DEFAULT = 1 * 1000; // 1 second per batched string
|
||||
|
||||
public enum SOURCE_LANGUAGE_OPTION { AUTO, PROMPT }
|
||||
|
||||
private ToolOptions stringOptions;
|
||||
private StringTranslationService serviceInstance;
|
||||
|
||||
public LibreTranslatePlugin(PluginTool tool) {
|
||||
super(tool);
|
||||
|
||||
initOptions();
|
||||
updateServiceInstance();
|
||||
}
|
||||
|
||||
private void initOptions() {
|
||||
HelpLocation helpLoc = new HelpLocation("LibreTranslatePlugin", "Configuration");
|
||||
|
||||
stringOptions = tool.getOptions(STRINGS_OPTION);
|
||||
stringOptions.registerOption(subOpt(URL_OPTION), STRING_TYPE, "", helpLoc,
|
||||
"LibreTranslate server URL. Required. Example: http://localhost:5000/");
|
||||
stringOptions.registerOption(subOpt(API_KEY_OPTION), STRING_TYPE, "", helpLoc,
|
||||
"LibreTranslate API Key. Optional, but possibly required by the server.");
|
||||
stringOptions.registerOption(subOpt(SOURCE_LANG_OPTION), OptionType.ENUM_TYPE,
|
||||
SOURCE_LANGUAGE_OPTION.AUTO, helpLoc,
|
||||
"Source language code option, either 'auto' or prompted each time.");
|
||||
stringOptions.registerOption(subOpt(TARGET_LANG_OPTION), STRING_TYPE, "en", helpLoc,
|
||||
"Target language code. Defaults to 'en'. See LibreTranslate's docs for list.");
|
||||
stringOptions.registerOption(subOpt(BATCHSIZE_OPTION), INT_TYPE, BATCHSIZE_DEFAULT, helpLoc,
|
||||
"Maximum number of requests to batch together.");
|
||||
stringOptions.registerOption(subOpt(HTTP_TIMEOUT_OPTION), INT_TYPE, TIMEOUT_DEFAULT,
|
||||
helpLoc,
|
||||
"Time to wait for HTTP requests to the LibreTranslate server to finish. (milliseconds)");
|
||||
stringOptions.registerOption(subOpt(HTTP_TIMEOUT_PER_ELEMENT_OPTION), INT_TYPE,
|
||||
TIMEOUT_PERSTRING_DEFAULT, helpLoc,
|
||||
"Additional time (per translated string) to wait for HTTP requests to finish. (milliseconds)");
|
||||
|
||||
stringOptions.addOptionsChangeListener(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispose() {
|
||||
if (stringOptions != null) {
|
||||
stringOptions.removeOptionsChangeListener(this);
|
||||
stringOptions = null;
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void optionsChanged(ToolOptions newOptions, String optionName, Object oldValue,
|
||||
Object newValue) {
|
||||
if (optionName.startsWith(subOpt(""))) {
|
||||
updateServiceInstance();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateServiceInstance() {
|
||||
if (serviceInstance != null) {
|
||||
deregisterService(StringTranslationService.class, serviceInstance);
|
||||
serviceInstance = null;
|
||||
}
|
||||
|
||||
String urlStr = stringOptions.getString(subOpt(URL_OPTION), "");
|
||||
String apiKey = stringOptions.getString(subOpt(API_KEY_OPTION), "");
|
||||
SOURCE_LANGUAGE_OPTION srcLangOption =
|
||||
stringOptions.getEnum(subOpt(SOURCE_LANG_OPTION), SOURCE_LANGUAGE_OPTION.AUTO);
|
||||
String targetLangCode = stringOptions.getString(subOpt(TARGET_LANG_OPTION), "en");
|
||||
|
||||
int batchSize = stringOptions.getInt(subOpt(BATCHSIZE_OPTION), BATCHSIZE_DEFAULT);
|
||||
batchSize = Math.clamp(batchSize, 1, BATCHSIZE_MAX);
|
||||
|
||||
int timeout = stringOptions.getInt(subOpt(HTTP_TIMEOUT_OPTION), TIMEOUT_DEFAULT);
|
||||
int timeoutPerString = stringOptions.getInt(subOpt(HTTP_TIMEOUT_PER_ELEMENT_OPTION),
|
||||
TIMEOUT_PERSTRING_DEFAULT);
|
||||
timeout = Math.clamp(timeout, 1000, 3600000 /* 1 hour */);
|
||||
timeoutPerString = Math.clamp(timeoutPerString, 1, 60 * 1000 /* 60 seconds */);
|
||||
|
||||
if (urlStr != null && !urlStr.isBlank()) {
|
||||
try {
|
||||
URI serverURI = URI.create(urlStr).toURL().toURI(); // round trip to URL to make sure it is valid
|
||||
serviceInstance = new LibreTranslateStringTranslationService(serverURI, apiKey,
|
||||
srcLangOption, targetLangCode, batchSize, timeout, timeoutPerString);
|
||||
}
|
||||
catch (IllegalArgumentException | MalformedURLException | URISyntaxException e) {
|
||||
Msg.warn(this, "Invalid URL for LibreTranslate option: " + urlStr);
|
||||
tool.setStatusInfo("Invalid URL for LibreTranslate option: " + urlStr);
|
||||
// fall thru
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceInstance == null) {
|
||||
// Create a non-functional stub instance that only displays the
|
||||
// Tool options for this LibreTranslate Service
|
||||
serviceInstance = new StringTranslationService() {
|
||||
@Override
|
||||
public void translate(Program program, List<ProgramLocation> stringLocations,
|
||||
TranslateOptions options) {
|
||||
OptionsService optionService = tool.getService(OptionsService.class);
|
||||
if (optionService != null) {
|
||||
optionService.showOptionsDialog(STRINGS_OPTION + "." + LT_OPTION, null);
|
||||
Swing.runLater(() -> {
|
||||
// if the serviceInstance was changed to a valid obj, re-try to translate the strings
|
||||
if (serviceInstance instanceof LibreTranslateStringTranslationService) {
|
||||
serviceInstance.translate(program, stringLocations, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTranslationServiceName() {
|
||||
return LIBRE_TRANSLATE_SERVICE_NAME;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerDynamicServiceProvided(StringTranslationService.class, serviceInstance);
|
||||
}
|
||||
|
||||
private String subOpt(String optName) {
|
||||
return LT_OPTION + Options.DELIMITER + optName;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,468 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package ghidra.app.plugin.core.string.translate.libretranslate;
|
||||
|
||||
import static ghidra.program.model.data.TranslationSettingsDefinition.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.net.http.HttpRequest.BodyPublisher;
|
||||
import java.net.http.HttpRequest.BodyPublishers;
|
||||
import java.net.http.HttpResponse.BodyHandler;
|
||||
import java.net.http.HttpResponse.BodyHandlers;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import com.google.gson.*;
|
||||
|
||||
import docking.widgets.SelectFromListDialog;
|
||||
import ghidra.app.plugin.core.string.translate.libretranslate.LibreTranslatePlugin.SOURCE_LANGUAGE_OPTION;
|
||||
import ghidra.app.services.StringTranslationService;
|
||||
import ghidra.net.HttpClients;
|
||||
import ghidra.program.model.data.DataUtilities;
|
||||
import ghidra.program.model.data.StringDataInstance;
|
||||
import ghidra.program.model.listing.Data;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.util.ProgramLocation;
|
||||
import ghidra.util.HelpLocation;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.task.*;
|
||||
|
||||
/**
|
||||
* Connects to an external LibreTranslate server via HTTP.
|
||||
*/
|
||||
public class LibreTranslateStringTranslationService implements StringTranslationService {
|
||||
static final String CONTENT_TYPE_JSON = "application/json";
|
||||
static final String CONTENT_TYPE_HEADER = "Content-Type";
|
||||
private static final String GHIDRA_USER_AGENT = "Ghidra";
|
||||
|
||||
private URI serverURI;
|
||||
private String apiKey;
|
||||
private SOURCE_LANGUAGE_OPTION sourceLanguageOption;
|
||||
private String targetLanguageCode;
|
||||
private List<SupportedLanguage> supportedLanguages;
|
||||
private int batchSize;
|
||||
private int maxRetryCount = 3;
|
||||
private int httpTimeout;
|
||||
private int httpTimeoutPerString;
|
||||
|
||||
/**
|
||||
* Creates an instance of {@link LibreTranslateStringTranslationService}
|
||||
*
|
||||
* @param serverURI URL of the LibreTranslate server
|
||||
* @param apiKey optional string, api key required to submit requests to the server
|
||||
* @param sourceLanguageOption {@link SOURCE_LANGUAGE_OPTION} enum
|
||||
* @param targetLanguageCode language code that the server should translate each string into
|
||||
* @param batchSize max number of strings to submit to the server per request
|
||||
* @param httpTimeout time to wait for a http request to finish
|
||||
* @param httpTimeoutPerString additional time per string element to wait for http request to finish
|
||||
*/
|
||||
public LibreTranslateStringTranslationService(URI serverURI, String apiKey,
|
||||
SOURCE_LANGUAGE_OPTION sourceLanguageOption, String targetLanguageCode, int batchSize,
|
||||
int httpTimeout, int httpTimeoutPerString) {
|
||||
String path = serverURI.getPath();
|
||||
this.serverURI = path.endsWith("/") ? serverURI : serverURI.resolve(path + "/");
|
||||
this.apiKey = Objects.requireNonNullElse(apiKey, "");
|
||||
this.sourceLanguageOption = sourceLanguageOption;
|
||||
this.targetLanguageCode = targetLanguageCode;
|
||||
this.batchSize = Math.clamp(batchSize, 1, batchSize);
|
||||
this.httpTimeout = Math.clamp(httpTimeout, 1, httpTimeout);
|
||||
this.httpTimeoutPerString = Math.clamp(httpTimeoutPerString, 1, httpTimeoutPerString);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTranslationServiceName() {
|
||||
return LibreTranslatePlugin.LIBRE_TRANSLATE_SERVICE_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HelpLocation getHelpLocation() {
|
||||
return new HelpLocation("LibreTranslatePlugin", "LibreTranslatePlugin");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void translate(Program program, List<ProgramLocation> stringLocations,
|
||||
TranslateOptions options) {
|
||||
Msg.info(this, "LibreTranslate translate %d strings using %s"
|
||||
.formatted(stringLocations.size(), serverURI));
|
||||
TaskLauncher.launchModal("Translate Strings", monitor -> {
|
||||
|
||||
monitor.initialize(stringLocations.size(), "Gathering strings");
|
||||
List<Entry<ProgramLocation, String>> sourceStrings = stringLocations.stream()
|
||||
.map(progLoc -> Map.entry(progLoc, DataUtilities.getDataAtLocation(progLoc)))
|
||||
.map(entry -> {
|
||||
monitor.incrementProgress();
|
||||
// convert progLoc->Data into progLoc->String
|
||||
StringDataInstance sdi =
|
||||
StringDataInstance.getStringDataInstance(entry.getValue());
|
||||
String s = sdi.getStringValue();
|
||||
return s != null ? Map.entry(entry.getKey(), s) : null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
String langCode;
|
||||
switch (sourceLanguageOption) {
|
||||
case PROMPT:
|
||||
try {
|
||||
ensureSupportedLanguages(monitor);
|
||||
SupportedLanguage selectedLang = SelectFromListDialog.selectFromList(
|
||||
supportedLanguages, "Choose Source Language", "Choose",
|
||||
SupportedLanguage::getDescription);
|
||||
if (selectedLang == null) {
|
||||
return;
|
||||
}
|
||||
langCode = selectedLang.langCode;
|
||||
}
|
||||
catch (IOException e) {
|
||||
showError("Error Fetching Supported Languages",
|
||||
"Failed to retrieve list of supported languages", e);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case AUTO:
|
||||
default:
|
||||
langCode = "auto";
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
List<ProgramLocation> successfulStrings =
|
||||
translate(program, sourceStrings, langCode, monitor);
|
||||
int failCount = sourceStrings.size() - successfulStrings.size();
|
||||
if (failCount > 0) {
|
||||
Msg.showWarn(this, null, "Translation Incomplete",
|
||||
"%d of %d strings not translated".formatted(failCount,
|
||||
stringLocations.size()));
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
showError("LibreTranslate Error", "Error when translating strings", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private List<ProgramLocation> translate(Program program,
|
||||
List<Entry<ProgramLocation, String>> srcStrings, String srcLangCode,
|
||||
TaskMonitor monitor) throws IOException {
|
||||
|
||||
List<ProgramLocation> successfulStrings = new ArrayList<>();
|
||||
|
||||
program.withTransaction("Translate strings", () -> {
|
||||
try {
|
||||
monitor.initialize(srcStrings.size(), "Translating strings");
|
||||
for (int srcIndex = 0; srcIndex < srcStrings.size(); srcIndex += batchSize) {
|
||||
monitor.checkCancelled();
|
||||
List<Entry<ProgramLocation, String>> subList = srcStrings.subList(srcIndex,
|
||||
Math.min(srcStrings.size(), srcIndex + batchSize));
|
||||
|
||||
List<String> subListStrs = subList.stream().map(e -> e.getValue()).toList();
|
||||
|
||||
long start_ts = System.currentTimeMillis();
|
||||
String jsonResponseStr = null;
|
||||
for (int retryCount = 0; retryCount < maxRetryCount; retryCount++) {
|
||||
// NOTE: some strings cause the LibreTranslate server to take much longer
|
||||
// to respond, which would be the most likely cause of timeout
|
||||
// issues. Repeating the same request will cause the same timeout error,
|
||||
// so instead the retryCount is used to scale the timeout.
|
||||
long timeout =
|
||||
(this.httpTimeout + (subListStrs.size() * httpTimeoutPerString)) *
|
||||
(retryCount + 1);
|
||||
|
||||
HttpRequest request =
|
||||
createTranslateRequest(subListStrs, srcLangCode, timeout);
|
||||
if (retryCount != 0) {
|
||||
monitor.setMessage(
|
||||
"Retrying translate request (%d)".formatted(retryCount));
|
||||
}
|
||||
try {
|
||||
jsonResponseStr =
|
||||
asyncRequest(request, BodyHandlers.ofString(), monitor);
|
||||
break;
|
||||
}
|
||||
catch (HttpTimeoutException e) {
|
||||
if (retryCount == maxRetryCount - 1) {
|
||||
throw new IOException(
|
||||
"Timeout during translate request, %d of %d strings completed"
|
||||
.formatted(successfulStrings.size(), srcStrings.size()),
|
||||
e);
|
||||
}
|
||||
Msg.error(this, "LibreTranslate timeout on translate request for: %s"
|
||||
.formatted(subListStrs));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> subResults = parseTranslateResponse(jsonResponseStr, subListStrs);
|
||||
for (int resultIndex = 0; resultIndex < subResults.size(); resultIndex++) {
|
||||
ProgramLocation progLoc = subList.get(resultIndex).getKey();
|
||||
// FUTURE feature: we could attempt to detect if the original string wasn't
|
||||
// translated by comparing the original string subListStrs.get(resultIndex)
|
||||
// with the xlatedValue.
|
||||
String xlatedValue = subResults.get(resultIndex);
|
||||
if (xlatedValue != null && !xlatedValue.trim().isEmpty()) {
|
||||
Data data = DataUtilities.getDataAtLocation(progLoc);
|
||||
TRANSLATION.setTranslatedValue(data, xlatedValue);
|
||||
TRANSLATION.setShowTranslated(data, true);
|
||||
monitor.increment();
|
||||
successfulStrings.add(progLoc);
|
||||
}
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - start_ts;
|
||||
int sps = subList.size() / Math.max(1, (int) (elapsed / 1000));
|
||||
Msg.debug(this, "LibreTranslate translate batch %d/%d strings, %dms"
|
||||
.formatted(successfulStrings.size(), srcStrings.size(), elapsed));
|
||||
monitor.setMessage(
|
||||
"Translating strings (%d strings per second)".formatted(sps));
|
||||
}
|
||||
}
|
||||
catch (CancelledException e) {
|
||||
// stop loop without error
|
||||
}
|
||||
});
|
||||
|
||||
Msg.info(this, "Finished LibreTranslate, %d/%d strings".formatted(srcStrings.size(),
|
||||
successfulStrings.size()));
|
||||
return successfulStrings;
|
||||
}
|
||||
|
||||
private HttpRequest createTranslateRequest(List<String> sourceStrings, String sourceLangCode,
|
||||
long timeout) throws IOException {
|
||||
Map<String, Object> requestParams = Map.of( // see libretranslate's website for api params
|
||||
"q", sourceStrings, // query strings
|
||||
"source", sourceLangCode, // source lang code, eg "yy", or "auto"
|
||||
"target", targetLanguageCode, // target lang code, eg. "en"
|
||||
"format", "text", // "text" or "html", we always want text
|
||||
"alternatives", 0, // TODO: not using alternative answers yet
|
||||
"api_key", apiKey);
|
||||
|
||||
HttpRequest request = request("translate", timeout) // build request
|
||||
.header(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON)
|
||||
.POST(ofJsonEncodedParams(requestParams))
|
||||
.build();
|
||||
return request;
|
||||
}
|
||||
|
||||
private List<String> parseTranslateResponse(String jsonResponseStr, List<String> requestedStrs)
|
||||
throws IOException {
|
||||
List<String> results = new ArrayList<>();
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(jsonResponseStr).getAsJsonObject();
|
||||
JsonElement xlatedTextEle = json.get("translatedText");
|
||||
if (xlatedTextEle == null) {
|
||||
throw new JsonSyntaxException("Bad json data for translatedText value");
|
||||
}
|
||||
JsonArray xlatedTexts = xlatedTextEle.getAsJsonArray();
|
||||
if (xlatedTexts.size() != requestedStrs.size()) {
|
||||
throw new IllegalStateException("LibreTranslate response size mismatch");
|
||||
}
|
||||
|
||||
// NOTE: if translate request was marked as "auto" source lang, the response will have
|
||||
// a "detectedLanguage" : { "confidence": int, "language": str } property.
|
||||
|
||||
for (int resultIndex = 0; resultIndex < xlatedTexts.size(); resultIndex++) {
|
||||
results.add(xlatedTexts.get(resultIndex).getAsString());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
catch (IllegalStateException | JsonSyntaxException e) {
|
||||
Msg.error(this, "Error parsing translate result: " + resultToSafeStr(jsonResponseStr));
|
||||
throw new IOException("Bad data in json response", e);
|
||||
}
|
||||
catch (Throwable th) {
|
||||
Msg.error(this, "Error parsing translate result: " + resultToSafeStr(jsonResponseStr));
|
||||
throw th;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a language supported by LibreTranslate
|
||||
* @param name language name
|
||||
* @param langCode 2 digit code
|
||||
* @param targets list of other languages that this language can be translated into
|
||||
*/
|
||||
public record SupportedLanguage(String name, String langCode, List<String> targets) {
|
||||
public String getDescription() {
|
||||
return "%s (%s)".formatted(name, langCode);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureSupportedLanguages(TaskMonitor monitor) throws IOException {
|
||||
try {
|
||||
if (supportedLanguages == null || supportedLanguages.isEmpty()) {
|
||||
supportedLanguages = getSupportedLanguages(monitor);
|
||||
if (supportedLanguages != null && !supportedLanguages.isEmpty()) {
|
||||
supportedLanguages.add(0,
|
||||
new SupportedLanguage("Autodetect", "auto", List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CancelledException e) {
|
||||
throw new IOException("Failed to get supported language list: request cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of languages that the LibreTranslate server supports.
|
||||
*
|
||||
* @param monitor {@link TaskMonitor}
|
||||
* @return list of {@link SupportedLanguage} records
|
||||
* @throws IOException if error connecting or excessive time to respond
|
||||
* @throws CancelledException if cancelled
|
||||
*/
|
||||
public List<SupportedLanguage> getSupportedLanguages(TaskMonitor monitor)
|
||||
throws IOException, CancelledException {
|
||||
HttpRequest request =
|
||||
request("languages").header(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON).GET().build();
|
||||
|
||||
String jsonResponseStr = asyncRequest(request, BodyHandlers.ofString(), monitor);
|
||||
|
||||
try {
|
||||
// [ { "code": "en", "name": "English", "targets": ["ar", ...
|
||||
JsonArray json = JsonParser.parseString(jsonResponseStr).getAsJsonArray();
|
||||
List<SupportedLanguage> results = new ArrayList<>();
|
||||
for (int i = 0; i < json.size(); i++) {
|
||||
SupportedLanguage supportedLang =
|
||||
parseSupportedLangJson(json.get(i).getAsJsonObject());
|
||||
results.add(supportedLang);
|
||||
}
|
||||
Collections.sort(results, (o1, o2) -> o1.langCode.compareTo(o2.langCode));
|
||||
return results;
|
||||
}
|
||||
catch (IllegalStateException | JsonSyntaxException e) {
|
||||
throw new IOException("Bad data in json response: " + jsonResponseStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
private SupportedLanguage parseSupportedLangJson(JsonObject obj) {
|
||||
if (obj.get("code") == null || obj.get("name") == null || obj.get("targets") == null) {
|
||||
throw new JsonSyntaxException("Bad json data for supported language");
|
||||
}
|
||||
String srcLangCode = obj.get("code").getAsString();
|
||||
String srcLangName = obj.get("name").getAsString();
|
||||
JsonArray targets = obj.get("targets").getAsJsonArray();
|
||||
List<String> targetLangCodes = new ArrayList<>();
|
||||
targets.forEach(targetEle -> {
|
||||
targetLangCodes.add(targetEle.getAsString());
|
||||
});
|
||||
return new SupportedLanguage(srcLangName, srcLangCode, targetLangCodes);
|
||||
}
|
||||
|
||||
private <T> T asyncRequest(HttpRequest request, BodyHandler<T> bodyHandler, TaskMonitor monitor)
|
||||
throws CancelledException, IOException {
|
||||
CompletableFuture<HttpResponse<T>> futureResponse =
|
||||
HttpClients.getHttpClient().sendAsync(request, bodyHandler);
|
||||
CancelledListener l = () -> futureResponse.cancel(true);
|
||||
monitor.addCancelledListener(l);
|
||||
|
||||
try {
|
||||
HttpResponse<T> response = futureResponse.get();
|
||||
|
||||
int statusCode = response.statusCode();
|
||||
if (statusCode != HttpURLConnection.HTTP_OK) {
|
||||
Msg.debug(this, "HTTP request [%s], response: %d, body: %s".formatted(request.uri(),
|
||||
statusCode, resultToSafeStr(response.body().toString())));
|
||||
throw new IOException(
|
||||
"Bad HTTP result [%d] for request [%s]".formatted(statusCode, request.uri()));
|
||||
}
|
||||
|
||||
String responseContentType =
|
||||
response.headers().firstValue(CONTENT_TYPE_HEADER).orElse("missing");
|
||||
if (!CONTENT_TYPE_JSON.equals(responseContentType)) {
|
||||
throw new IOException(
|
||||
"Bad content-type in result: [%s]".formatted(responseContentType));
|
||||
}
|
||||
|
||||
return response.body();
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new CancelledException("Request canceled");
|
||||
}
|
||||
catch (ExecutionException e) {
|
||||
// if possible, unwrap the exception that happened inside the future
|
||||
Throwable cause = e.getCause();
|
||||
Msg.error(this, "Error during HTTP request [%s]".formatted(request.uri()), cause);
|
||||
throw (cause instanceof IOException)
|
||||
? (IOException) cause
|
||||
: new IOException("Error during HTTP request", cause);
|
||||
}
|
||||
finally {
|
||||
monitor.removeCancelledListener(l);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static BodyPublisher ofJsonEncodedParams(Map<String, Object> params) {
|
||||
JsonObject obj = new JsonObject();
|
||||
params.forEach((k, v) -> {
|
||||
if (v instanceof String str) {
|
||||
obj.addProperty(k, str);
|
||||
}
|
||||
else if (v instanceof Number num) {
|
||||
obj.addProperty(k, num);
|
||||
}
|
||||
else if (v instanceof Boolean bool) {
|
||||
obj.addProperty(k, bool);
|
||||
}
|
||||
else if (v instanceof List list) {
|
||||
JsonArray jsonArray = new JsonArray();
|
||||
for (Object listEle : list) {
|
||||
jsonArray.add(listEle.toString());
|
||||
}
|
||||
obj.add(k, jsonArray);
|
||||
}
|
||||
});
|
||||
return BodyPublishers.ofString(obj.toString());
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(String str) throws IOException {
|
||||
return request(str, httpTimeout);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(String str, long timeoutMS) throws IOException {
|
||||
try {
|
||||
return HttpRequest.newBuilder(serverURI.resolve(str))
|
||||
.timeout(Duration.ofMillis(timeoutMS))
|
||||
.setHeader("User-Agent", GHIDRA_USER_AGENT);
|
||||
}
|
||||
catch (IllegalArgumentException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void showError(String title, String msg, Throwable th) {
|
||||
String summary = th.getMessage();
|
||||
if (summary == null || summary.isBlank()) {
|
||||
summary = th.getClass().getSimpleName();
|
||||
}
|
||||
Msg.showError(this, null, title,
|
||||
"%s: %s\n\nLibreTranslate server URL: %s".formatted(msg, summary, serverURI), th);
|
||||
|
||||
}
|
||||
|
||||
private String resultToSafeStr(String s) {
|
||||
if (s.length() > 200) {
|
||||
s = s.substring(0, 200) + "....";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
@ -29,6 +29,11 @@ import ghidra.util.HelpLocation;
|
||||
* <p>
|
||||
* Implementations of this interface are usually done via a Plugin
|
||||
* and then registered via {@link Plugin}'s registerServiceProvided().
|
||||
* <p>
|
||||
* Consumers of this service should expect multiple instance types to be returned from
|
||||
* {@link PluginTool#getServices(Class)}, and should add a service listener via
|
||||
* {@link PluginTool#addServiceListener(ghidra.framework.plugintool.util.ServiceListener)}
|
||||
* if service instances are retained to be notified when service instances are changed.
|
||||
*/
|
||||
public interface StringTranslationService {
|
||||
/**
|
||||
@ -37,10 +42,10 @@ public interface StringTranslationService {
|
||||
* @param tool {@link PluginTool}
|
||||
* @return sorted list of currently enabled StringTranslationServices
|
||||
*/
|
||||
public static List<StringTranslationService> getCurrentStringTranslationServices(
|
||||
static List<StringTranslationService> getCurrentStringTranslationServices(
|
||||
PluginTool tool) {
|
||||
List<StringTranslationService> translationServices =
|
||||
new ArrayList<>(Arrays.asList(tool.getServices(StringTranslationService.class)));
|
||||
new ArrayList<>(List.of(tool.getServices(StringTranslationService.class)));
|
||||
Collections.sort(translationServices,
|
||||
(s1, s2) -> s1.getTranslationServiceName().compareTo(s2.getTranslationServiceName()));
|
||||
return translationServices;
|
||||
@ -52,7 +57,7 @@ public interface StringTranslationService {
|
||||
*
|
||||
* @return string name.
|
||||
*/
|
||||
public String getTranslationServiceName();
|
||||
String getTranslationServiceName();
|
||||
|
||||
/**
|
||||
* Returns the {@link HelpLocation} instance that describes where to direct the user
|
||||
@ -60,7 +65,7 @@ public interface StringTranslationService {
|
||||
*
|
||||
* @return {@link HelpLocation} instance or null.
|
||||
*/
|
||||
public default HelpLocation getHelpLocation() {
|
||||
default HelpLocation getHelpLocation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -71,13 +76,21 @@ public interface StringTranslationService {
|
||||
*
|
||||
* @param program the program containing the data instances.
|
||||
* @param stringLocations {@link List} of string locations.
|
||||
* @param options {@link TranslateOptions}
|
||||
*/
|
||||
public void translate(Program program, List<ProgramLocation> stringLocations,
|
||||
void translate(Program program, List<ProgramLocation> stringLocations,
|
||||
TranslateOptions options);
|
||||
|
||||
/**
|
||||
* Options that are given by the callers of
|
||||
* {@link StringTranslationService#translate(Program, List, TranslateOptions)}.
|
||||
*
|
||||
* @param autoTranslate boolean flag, if true the translation service instance should try
|
||||
* to translate the values without user interaction
|
||||
*/
|
||||
public record TranslateOptions(boolean autoTranslate) {
|
||||
public static TranslateOptions NONE = new TranslateOptions(false);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that creates a {@link HelpLocation} based on the plugin and sts.
|
||||
@ -87,7 +100,7 @@ public interface StringTranslationService {
|
||||
* @return HelpLocation with topic equal to the plugin name and anchor something like
|
||||
* "MyTranslationServiceName_String_Translation_Service".
|
||||
*/
|
||||
public static HelpLocation createStringTranslationServiceHelpLocation(
|
||||
static HelpLocation createStringTranslationServiceHelpLocation(
|
||||
Class<? extends Plugin> pluginClass, StringTranslationService sts) {
|
||||
return new HelpLocation(PluginDescription.getPluginDescription(pluginClass).getName(),
|
||||
sts.getTranslationServiceName() + "_String_Translation_Service");
|
||||
|
@ -0,0 +1,512 @@
|
||||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package ghidra.app.plugin.core.string.translate.libretranslate;
|
||||
|
||||
import static ghidra.app.plugin.core.string.translate.libretranslate.LibreTranslatePlugin.SOURCE_LANGUAGE_OPTION.*;
|
||||
import static ghidra.app.plugin.core.string.translate.libretranslate.LibreTranslateStringTranslationService.*;
|
||||
import static java.net.HttpURLConnection.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.sun.net.httpserver.*;
|
||||
|
||||
import docking.AbstractErrDialog;
|
||||
import docking.widgets.SelectFromListDialog;
|
||||
import ghidra.app.plugin.core.string.translate.libretranslate.LibreTranslatePlugin.SOURCE_LANGUAGE_OPTION;
|
||||
import ghidra.app.plugin.core.string.translate.libretranslate.LibreTranslateStringTranslationService.SupportedLanguage;
|
||||
import ghidra.app.services.StringTranslationService.TranslateOptions;
|
||||
import ghidra.program.database.ProgramDB;
|
||||
import ghidra.program.model.data.*;
|
||||
import ghidra.program.model.listing.Data;
|
||||
import ghidra.program.util.ProgramLocation;
|
||||
import ghidra.test.AbstractProgramBasedTest;
|
||||
import ghidra.test.ToyProgramBuilder;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.Swing;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
|
||||
/**
|
||||
* Tests the LibreTranslateStringTranslationService by creating a mock HTTP server that responds
|
||||
* to the minimal requests the service needs.
|
||||
*/
|
||||
public class LibreTranslateStringTranslationServiceTest extends AbstractProgramBasedTest {
|
||||
|
||||
private static int LAST_SERVER_PORT_NUM = 8000 + 5000;
|
||||
private int supportedLanguageCount = 10;
|
||||
private AtomicInteger translateRequestCount = new AtomicInteger(); // number of times translate handler has been invoked
|
||||
private AtomicInteger translateStringCount = new AtomicInteger(); // number of strings that translate handler has processed
|
||||
private List<String> translateSourceLangs = Collections.synchronizedList(new ArrayList<>());
|
||||
private List<Data> strings = new ArrayList<>();
|
||||
|
||||
@Test
|
||||
public void testWrongServerURL() throws IOException {
|
||||
// test what happens when the server returns 404's for the REST api requests
|
||||
HttpServer server = createMockHttpServer();
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null,
|
||||
AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
setErrorsExpected(true); // don't kill the test because Msg.showError() was called somewhere
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
setErrorsExpected(false);
|
||||
|
||||
waitForTasks();
|
||||
|
||||
// we SHOULD get an error dialog because got bad http status result
|
||||
AbstractErrDialog errDlg = waitForErrorDialog();
|
||||
errDlg.dispose();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIncompatibleJSonResponse() throws IOException {
|
||||
// test what happens when the server accepts requests on the REST api endpoint URL, but
|
||||
// returns unexpected json values
|
||||
|
||||
HttpServer server = createMockHttpServer(false);
|
||||
server.createContext("/", this::mockUnexpectedJsonResultHandler);
|
||||
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null,
|
||||
AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
setErrorsExpected(true); // don't kill the test because Msg.showError() was called somewhere
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
setErrorsExpected(false);
|
||||
|
||||
waitForTasks();
|
||||
|
||||
// we SHOULD get an error dialog because got bad http status result
|
||||
AbstractErrDialog errDlg = waitForErrorDialog();
|
||||
errDlg.dispose();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIncompatibleServerURL() throws IOException {
|
||||
// test what happens when the server accepts requests on the REST api endpoint URL, but its
|
||||
// not json
|
||||
|
||||
HttpServer server = createMockHttpServer(false);
|
||||
server.createContext("/", this::mockUnexpectedTextResultHandler);
|
||||
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null,
|
||||
AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
setErrorsExpected(true); // don't kill the test because Msg.showError() was called somewhere
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
setErrorsExpected(false);
|
||||
|
||||
waitForTasks();
|
||||
|
||||
// we SHOULD get an error dialog because got bad http status result
|
||||
AbstractErrDialog errDlg = waitForErrorDialog();
|
||||
errDlg.dispose();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoResponseFromURL() {
|
||||
// test what happens when the URL doesn't point to active server
|
||||
|
||||
LibreTranslateStringTranslationService sts = new LibreTranslateStringTranslationService(
|
||||
getURI(nextUnusedAddr()), null, AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
setErrorsExpected(true); // don't kill the test because Msg.showError() was called somewhere
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
setErrorsExpected(false);
|
||||
|
||||
waitForTasks();
|
||||
|
||||
// we SHOULD get an error dialog because got bad http status result
|
||||
AbstractErrDialog errDlg = waitForErrorDialog();
|
||||
errDlg.dispose();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTranslateRequest() throws IOException {
|
||||
HttpServer server = createMockHttpServer();
|
||||
server.createContext("/translate", this::mockTranslateHandler);
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null,
|
||||
AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
assertEquals(translateRequestCount.get(), 1);
|
||||
|
||||
String xlatedStr =
|
||||
TranslationSettingsDefinition.TRANSLATION.getTranslatedValue(strings.get(0));
|
||||
assertEquals("result0", xlatedStr);
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
public void testPromptForSourceLangTranslateRequest() throws IOException, CancelledException {
|
||||
HttpServer server = createMockHttpServer();
|
||||
server.createContext("/translate", this::mockTranslateHandler);
|
||||
server.createContext("/languages", this::mockLangHandler);
|
||||
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null,
|
||||
SOURCE_LANGUAGE_OPTION.PROMPT, "en", 100, 1000, 1000);
|
||||
List<SupportedLanguage> langs = sts.getSupportedLanguages(TaskMonitor.DUMMY);
|
||||
SupportedLanguage langToPick = langs.get(1);
|
||||
|
||||
Swing.runLater(
|
||||
() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
|
||||
@SuppressWarnings({ "rawtypes" })
|
||||
SelectFromListDialog selectDlg = waitForDialogComponent(SelectFromListDialog.class);
|
||||
|
||||
Swing.runNow(() -> selectDlg.setSelectedObject(langToPick.getDescription()));
|
||||
Swing.runNow(() -> pressButtonByText(selectDlg, "OK"));
|
||||
|
||||
waitForTasks();
|
||||
|
||||
assertEquals(translateRequestCount.get(), 1);
|
||||
assertEquals(1, translateSourceLangs.size());
|
||||
assertEquals(langToPick.langCode(), translateSourceLangs.get(0));
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 20000)
|
||||
public void testTimeoutTranslateRequest() throws IOException {
|
||||
HttpServer server = createMockHttpServer();
|
||||
|
||||
server.createContext("/translate", wrapHandlerWithDelay(this::mockTranslateHandler, 5000));
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts = new LibreTranslateStringTranslationService(
|
||||
getURI(server.getAddress()), null, AUTO, "en", 100, 1000, 1);
|
||||
|
||||
Swing.runLater(() -> {
|
||||
setErrorsExpected(true); // don't kill the test because Msg.showError() was called somewhere
|
||||
sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE);
|
||||
setErrorsExpected(false);
|
||||
});
|
||||
|
||||
waitForTasks();
|
||||
|
||||
// we SHOULD get an error dialog because we forced the http request to timeout
|
||||
AbstractErrDialog errDlg = waitForErrorDialog();
|
||||
errDlg.dispose();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 20000)
|
||||
public void testRetryTranslateRequest() throws IOException {
|
||||
HttpServer server = createMockHttpServer();
|
||||
server.createContext("/translate", wrapHandlerWithDelay(this::mockTranslateHandler, 2000));
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts = new LibreTranslateStringTranslationService(
|
||||
getURI(server.getAddress()), null, AUTO, "en", 100, 1500, 1);
|
||||
|
||||
Swing.runNow(() -> sts.translate(program, List.of(progLoc(0)), TranslateOptions.NONE));
|
||||
assertTrue(translateRequestCount.get() > 1);
|
||||
waitForTasks();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBatchTranslateRequest() throws IOException {
|
||||
int batchSize = 2;
|
||||
HttpServer server = createMockHttpServer();
|
||||
server.createContext("/translate", this::mockTranslateHandler);
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts =
|
||||
new LibreTranslateStringTranslationService(getURI(server.getAddress()), null, AUTO,
|
||||
"en", batchSize, 1000, 1000);
|
||||
|
||||
List<ProgramLocation> stringLocs = strings.stream()
|
||||
.map(data -> new ProgramLocation(program, data.getAddress()))
|
||||
.toList();
|
||||
int expectedBatchCount = stringLocs.size() / batchSize;
|
||||
expectedBatchCount += (stringLocs.size() % batchSize != 0 ? 1 : 0);
|
||||
|
||||
Swing.runNow(() -> sts.translate(program, stringLocs, TranslateOptions.NONE));
|
||||
|
||||
assertEquals(translateRequestCount.get(), expectedBatchCount);
|
||||
assertEquals(translateStringCount.get(), stringLocs.size());
|
||||
waitForTasks();
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLanguagesRequest() throws IOException, CancelledException {
|
||||
HttpServer server = createMockHttpServer();
|
||||
server.createContext("/languages", this::mockLangHandler);
|
||||
try {
|
||||
server.start();
|
||||
LibreTranslateStringTranslationService sts = new LibreTranslateStringTranslationService(
|
||||
getURI(server.getAddress()), null, AUTO, "en", 100, 1000, 1000);
|
||||
|
||||
List<SupportedLanguage> langs = sts.getSupportedLanguages(TaskMonitor.DUMMY);
|
||||
assertEquals(supportedLanguageCount, langs.size());
|
||||
}
|
||||
finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------------------------------
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ProgramDB getProgram() throws Exception {
|
||||
ToyProgramBuilder builder = new ToyProgramBuilder("String Examples", false);
|
||||
builder.createMemory("RAM", "0x0", 0x500);
|
||||
|
||||
strings.add(builder.createString("0x100", "Hello World!\n", StandardCharsets.US_ASCII, true,
|
||||
StringDataType.dataType));
|
||||
strings.add(builder.createString("0x10e", "Next string", StandardCharsets.US_ASCII, true,
|
||||
StringDataType.dataType));
|
||||
|
||||
strings.add(builder.createString("0x150", bytes(0, 1, 2, 3, 4, 0x80, 0x81, 0x82, 0x83),
|
||||
StandardCharsets.US_ASCII, StringDataType.dataType));
|
||||
|
||||
strings.add(
|
||||
builder.createString("0x200", "\u6211\u96bb\u6c23\u588a\u8239\u88dd\u6eff\u6652\u9c54",
|
||||
StandardCharsets.UTF_16, true, UnicodeDataType.dataType));
|
||||
|
||||
strings.add(builder.createString("0x250", "Exception %s\n\tline: %d\n",
|
||||
StandardCharsets.US_ASCII, true, StringDataType.dataType));
|
||||
|
||||
strings.add(builder.createString("0x330",
|
||||
"A: \u6211\u96bb\u6c23\u588a\u8239\u88dd\u6eff\u6652\u9c54", StandardCharsets.UTF_8,
|
||||
true, StringUTF8DataType.dataType));
|
||||
|
||||
strings.add(builder.createString("0x450",
|
||||
"Roses are \u001b[0;31mred\u001b[0m, violets are \u001b[0;34mblue. Hope you enjoy terminal hue",
|
||||
StandardCharsets.US_ASCII, true, StringDataType.dataType));
|
||||
|
||||
return builder.getProgram();
|
||||
}
|
||||
|
||||
private URI getURI(InetSocketAddress addr) {
|
||||
return URI.create("http://%s:%d".formatted(addr.getHostString(), addr.getPort()));
|
||||
}
|
||||
|
||||
private HttpServer createMockHttpServer() throws IOException {
|
||||
return createMockHttpServer(true);
|
||||
}
|
||||
|
||||
private HttpServer createMockHttpServer(boolean addDefaultHandler) throws IOException {
|
||||
IOException lastException = null;
|
||||
for (int retryNum = 0; retryNum < 10; retryNum++) {
|
||||
LAST_SERVER_PORT_NUM++; // don't try to reuse the same server port num in the same session
|
||||
InetSocketAddress serverAddress =
|
||||
new InetSocketAddress(InetAddress.getLoopbackAddress(), LAST_SERVER_PORT_NUM);
|
||||
|
||||
try {
|
||||
HttpServer server = HttpServer.create(serverAddress, 0);
|
||||
if (addDefaultHandler) {
|
||||
server.createContext("/", this::mock404Handler);
|
||||
}
|
||||
return server;
|
||||
}
|
||||
catch (IOException e) {
|
||||
// ignore, just try again with next port num
|
||||
lastException = e;
|
||||
}
|
||||
}
|
||||
throw new IOException(
|
||||
"Could not allocate port for mock http server, last attempted port: " +
|
||||
LAST_SERVER_PORT_NUM,
|
||||
lastException);
|
||||
}
|
||||
|
||||
private InetSocketAddress nextUnusedAddr() {
|
||||
LAST_SERVER_PORT_NUM++;
|
||||
return new InetSocketAddress(InetAddress.getLoopbackAddress(), LAST_SERVER_PORT_NUM);
|
||||
}
|
||||
|
||||
private void assertContentType(HttpExchange httpExchange, String expectedType) {
|
||||
String contentType = httpExchange.getRequestHeaders()
|
||||
.getFirst(LibreTranslateStringTranslationService.CONTENT_TYPE_HEADER);
|
||||
contentType = Objects.requireNonNullElse(contentType, "missing");
|
||||
if (!expectedType.equals(contentType)) {
|
||||
fail("Content type incorrect: expected: %s, actual: %s".formatted(expectedType,
|
||||
contentType));
|
||||
}
|
||||
}
|
||||
|
||||
private void log(HttpExchange httpExchange, String msg) {
|
||||
Msg.info(this, "[%s %s] %s".formatted(httpExchange.getLocalAddress(),
|
||||
httpExchange.getRequestURI(), msg));
|
||||
|
||||
}
|
||||
|
||||
private void mockLangHandler(HttpExchange httpExchange) throws IOException {
|
||||
assertContentType(httpExchange, CONTENT_TYPE_JSON);
|
||||
try {
|
||||
JsonArray langsResult = new JsonArray();
|
||||
for (int i = 0; i < supportedLanguageCount; i++) {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("code", "%c%c".formatted('a' + i, 'a' + i));
|
||||
obj.addProperty("name", "Language " + i);
|
||||
JsonArray targets = new JsonArray();
|
||||
obj.add("targets", targets);
|
||||
langsResult.add(obj);
|
||||
}
|
||||
byte[] response = langsResult.toString().getBytes();
|
||||
|
||||
httpExchange.getResponseHeaders().set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON);
|
||||
httpExchange.sendResponseHeaders(HTTP_OK, response.length);
|
||||
httpExchange.getResponseBody().write(response);
|
||||
}
|
||||
finally {
|
||||
httpExchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void mockTranslateHandler(HttpExchange httpExchange) throws IOException {
|
||||
try {
|
||||
translateRequestCount.incrementAndGet();
|
||||
|
||||
assertContentType(httpExchange, CONTENT_TYPE_JSON);
|
||||
|
||||
String requestBody =
|
||||
new String(httpExchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
JsonObject request = JsonParser.parseString(requestBody).getAsJsonObject();
|
||||
JsonArray queryStrs = request.get("q").getAsJsonArray();
|
||||
String sourceLang = request.get("source").getAsString();
|
||||
|
||||
translateSourceLangs.add(sourceLang);
|
||||
|
||||
log(httpExchange,
|
||||
"request src=%s, strs=%s".formatted(sourceLang, queryStrs.toString()));
|
||||
|
||||
JsonObject xlateResultObj = new JsonObject();
|
||||
JsonArray xlatedResults = new JsonArray();
|
||||
xlateResultObj.add("translatedText", xlatedResults);
|
||||
for (int i = 0; i < queryStrs.size(); i++) {
|
||||
xlatedResults.add("result" + translateStringCount.getAndIncrement());
|
||||
}
|
||||
log(httpExchange, "response: " + xlateResultObj);
|
||||
byte[] response = xlateResultObj.toString().getBytes();
|
||||
|
||||
httpExchange.getResponseHeaders().set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON);
|
||||
httpExchange.sendResponseHeaders(HTTP_OK, response.length);
|
||||
httpExchange.getResponseBody().write(response);
|
||||
}
|
||||
catch (Throwable th) {
|
||||
log(httpExchange, "Error during mockTranslateHandler: " + th.getMessage());
|
||||
throw th;
|
||||
}
|
||||
finally {
|
||||
httpExchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void mockUnexpectedJsonResultHandler(HttpExchange httpExchange) throws IOException {
|
||||
JsonObject jsonObj = new JsonObject();
|
||||
jsonObj.addProperty("something", "an unexpected value");
|
||||
byte[] response = jsonObj.toString().getBytes();
|
||||
|
||||
httpExchange.getResponseHeaders().set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON);
|
||||
httpExchange.sendResponseHeaders(HTTP_OK, response.length);
|
||||
httpExchange.getResponseBody().write(response);
|
||||
httpExchange.close();
|
||||
}
|
||||
|
||||
private void mockUnexpectedTextResultHandler(HttpExchange httpExchange) throws IOException {
|
||||
// this returns OK for every URL
|
||||
byte[] response = "Hello world".toString().getBytes();
|
||||
|
||||
httpExchange.getResponseHeaders().set(CONTENT_TYPE_HEADER, "text/plain");
|
||||
httpExchange.sendResponseHeaders(HTTP_OK, response.length);
|
||||
httpExchange.getResponseBody().write(response);
|
||||
httpExchange.close();
|
||||
}
|
||||
|
||||
private HttpHandler wrapHandlerWithDelay(HttpHandler delegate, int delayMS) {
|
||||
return httpExchange -> {
|
||||
try {
|
||||
Thread.sleep(delayMS);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
// ignore
|
||||
}
|
||||
delegate.handle(httpExchange);
|
||||
};
|
||||
}
|
||||
|
||||
private void mock404Handler(HttpExchange httpExchange) throws IOException {
|
||||
try {
|
||||
httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_NOT_FOUND, 0);
|
||||
}
|
||||
finally {
|
||||
httpExchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private ProgramLocation progLoc(int stringNum) {
|
||||
return new ProgramLocation(program, strings.get(stringNum).getAddress());
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user