7 Commits ea028c0d11 ... 90047e60b0

Author SHA1 Message Date
  randoman 90047e60b0 Version 3.2.0 6 years ago
  randoman f46dc0ccc4 Version 3.2.0 6 years ago
  randoman e99f5306ba support for multiple root folders for plugins, translation and config 6 years ago
  randoman 5d1444364d bepinex 5.x support 6 years ago
  randoman 0d7f36362b work on seperate translation feature 6 years ago
  randoman 10a474f68d work on splitting out code from main MB 6 years ago
  randoman eb7313a5a2 BUG FIX - Interacting with UI now blocks input to game 6 years ago
64 changed files with 3274 additions and 1206 deletions
  1. 12 1
      CHANGELOG.md
  2. 39 15
      README.md
  3. 11 0
      XUnity.AutoTranslator.sln
  4. BIN
      libs/BepInEx 5.0/BepInEx.Harmony.dll
  5. 51 0
      libs/BepInEx 5.0/BepInEx.Harmony.xml
  6. BIN
      libs/BepInEx 5.0/BepInEx.Preloader.dll
  7. BIN
      libs/BepInEx 5.0/BepInEx.dll
  8. 7 1
      src/Translators/BingTranslate/BingTranslateEndpoint.cs
  9. 7 1
      src/Translators/GoogleTranslate/GoogleTranslateEndpoint.cs
  10. 1 1
      src/XUnity.AutoTranslator.Patcher/Patcher.cs
  11. 69 0
      src/XUnity.AutoTranslator.Plugin.BepIn-5x/AutoTranslatorPlugin.cs
  12. 42 0
      src/XUnity.AutoTranslator.Plugin.BepIn-5x/BepInLogger.cs
  13. 37 0
      src/XUnity.AutoTranslator.Plugin.BepIn-5x/XUnity.AutoTranslator.Plugin.BepIn-5x.csproj
  14. 8 3
      src/XUnity.AutoTranslator.Plugin.BepIn/AutoTranslatorPlugin.cs
  15. 1 1
      src/XUnity.AutoTranslator.Plugin.BepIn/XUnity.AutoTranslator.Plugin.BepIn.csproj
  16. 186 735
      src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs
  17. 106 87
      src/XUnity.AutoTranslator.Plugin.Core/Configuration/Settings.cs
  18. 3 0
      src/XUnity.AutoTranslator.Plugin.Core/Constants/ClrTypes.cs
  19. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Constants/PluginData.cs
  20. 4 4
      src/XUnity.AutoTranslator.Plugin.Core/Constants/UserAgents.cs
  21. 23 12
      src/XUnity.AutoTranslator.Plugin.Core/Debugging/DebugConsole.cs
  22. 5 7
      src/XUnity.AutoTranslator.Plugin.Core/DefaultPluginEnvironment.cs
  23. 0 101
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/ConfiguredEndpoint.cs
  24. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/InitializationContext.cs
  25. 0 66
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/KnownTranslateEndpoints.cs
  26. 565 0
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/TranslationEndpointManager.cs
  27. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Extensions/HarmonyInstanceExtensions.cs
  28. 20 1
      src/XUnity.AutoTranslator.Plugin.Core/Extensions/TextComponentExtensions.cs
  29. 106 0
      src/XUnity.AutoTranslator.Plugin.Core/Hooks/IMGUIHooks.cs
  30. 22 0
      src/XUnity.AutoTranslator.Plugin.Core/Hooks/ImageHooks.cs
  31. 11 1
      src/XUnity.AutoTranslator.Plugin.Core/IPluginEnvironment.cs
  32. 129 0
      src/XUnity.AutoTranslator.Plugin.Core/ITranslator.cs
  33. 29 0
      src/XUnity.AutoTranslator.Plugin.Core/ParserTranslationContext.cs
  34. 228 0
      src/XUnity.AutoTranslator.Plugin.Core/SpamChecker.cs
  35. 223 0
      src/XUnity.AutoTranslator.Plugin.Core/TextTranslationCache.cs
  36. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/TextTranslationInfo.cs
  37. 158 0
      src/XUnity.AutoTranslator.Plugin.Core/TextureTranslationCache.cs
  38. 0 21
      src/XUnity.AutoTranslator.Plugin.Core/TranslationContext.cs
  39. 26 30
      src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs
  40. 224 0
      src/XUnity.AutoTranslator.Plugin.Core/TranslationManager.cs
  41. 63 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/AggregatedTranslationViewModel.cs
  42. 25 34
      src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownGUI.cs
  43. 38 6
      src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownOptionViewModel.cs
  44. 36 6
      src/XUnity.AutoTranslator.Plugin.Core/UI/GUIUtil.cs
  45. 70 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslationViewModel.cs
  46. 15 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslatorTranslationViewModel.cs
  47. 24 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/ScrollViewGUI.cs
  48. 4 1
      src/XUnity.AutoTranslator.Plugin.Core/UI/ToggleViewModel.cs
  49. 34 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/Translation.cs
  50. 104 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorOptionsWindow.cs
  51. 154 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorViewModel.cs
  52. 189 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorWindow.cs
  53. 17 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslatorViewModel.cs
  54. 33 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/XuaViewModel.cs
  55. 57 58
      src/XUnity.AutoTranslator.Plugin.Core/UI/XuaWindow.cs
  56. 14 0
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/CoroutineHelper.cs
  57. 20 0
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/LanguageHelper.cs
  58. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/UtageHelper.cs
  59. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/XUnity.AutoTranslator.Plugin.Core.csproj
  60. 8 3
      src/XUnity.AutoTranslator.Plugin.IPA/AutoTranslatorPlugin.cs
  61. 1 1
      src/XUnity.AutoTranslator.Plugin.IPA/XUnity.AutoTranslator.Plugin.IPA.csproj
  62. 6 0
      src/XUnity.AutoTranslator.Plugin.UnityInjector/AutoTranslatorPlugin.cs
  63. 2 2
      src/XUnity.AutoTranslator.Plugin.UnityInjector/XUnity.AutoTranslator.Plugin.UnityInjector.csproj
  64. 1 1
      src/XUnity.AutoTranslator.Setup/XUnity.AutoTranslator.Setup.csproj

+ 12 - 1
CHANGELOG.md

@@ -1,4 +1,15 @@
-### 3.1.0
+### 3.2.0
+ * FEATURE - BepInEx 5.x plugin support
+ * CHANGE - Restructured large portions of the internal code to support more features going forward
+ * BUG FIX - Interacting with UI now blocks input to game
+ * BUG FIX - Better handling of error'ed translations in relation to rich text
+ * BUG FIX - Minor fixes to 'copy to clipboard' to disable IMGUI spam
+ * BUG FIX - Fixed potential NullReferenceException in GoogleTranslate and BingTranslate during timeout errors
+ * MISC - Removed 'Dump Untranslated Texts' hotkey due to feature bloat
+ * MISC - Allow unselecting translation endpoint in UI
+ * MISC - Increased request timeout from 50 to 150 seconds to ensure better error logging of failed requests
+
+### 3.1.0
  * FEATURE - Support for games with 'netstandard2.0' API surface through config option 'EnableExperimentalHooks'
  * BUG FIX - Bug fixes and improvements to Utage hooking implementation - EnableUtage config option also removed (always on now)
  * BUG FIX - Rich text parser bug fixes when only a single tag with no ending was used

+ 39 - 15
README.md

@@ -2,11 +2,11 @@
 
 ## Index
  * [Introduction](#introduction)
+ * [Plugin Frameworks](#plugin-frameworks)
  * [Installation](#installation)
  * [Key Mapping](#key-mapping)
  * [Translators](#translators)
  * [Text Frameworks](#text-frameworks)
- * [Plugin Frameworks](#plugin-frameworks)
  * [Configuration](#configuration)
  * [Frequently Asked Questions](#frequently-asked-questions)
  * [Translating Mods](#translating-mods)
@@ -27,10 +27,20 @@ If you intend on redistributing this plugin as part of a translation suite for a
 
 From version 3.0.0 it is possible to implement custom translators. See [this section](#implementing-a-translator) for more info.
 
+## Plugin Frameworks
+The mod can be installed into the following Plugin Managers:
+ * [BepInEx](https://github.com/bbepis/BepInEx) (preferred approach)
+ * [IPA](https://github.com/Eusth/IPA)
+ * UnityInjector
+
+Installation instructions for all methods can be found below.
+
+Additionally it can be installed without a dependency on a plugin manager through ReiPatcher. However, this approach is not recommended if you use one of the above mentioned Plugin Managers!
+
 ## Installation
 The plugin can be installed in following ways:
 
-### BepInEx Plugin
+### BepInEx (4.x) Plugin
 REQUIRES: [BepInEx plugin manager](https://github.com/BepInEx/BepInEx) (follow its installation instructions first!). 
 
  1. Download XUnity.AutoTranslator-BepIn-{VERSION}.zip from [releases](../../releases).
@@ -41,11 +51,31 @@ The file structure should like like this:
 {GameDirectory}/BepInEx/XUnity.AutoTranslator.Plugin.Core.dll
 {GameDirectory}/BepInEx/XUnity.AutoTranslator.Plugin.BepInEx.dll
 {GameDirectory}/BepInEx/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
+{GameDirectory}/BepInEx/XUnity.RuntimeHooker.dll
+{GameDirectory}/BepInEx/XUnity.RuntimeHooker.Core.dll
 {GameDirectory}/BepInEx/ExIni.dll
 {GameDirectory}/BepInEx/Translators/{Translator}.dll
 {GameDirectory}/BepInEx/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
 ```
 
+### BepInEx (5.x) Plugin
+REQUIRES: [BepInEx plugin manager](https://github.com/BepInEx/BepInEx) (follow its installation instructions first!). 
+
+ 1. Download XUnity.AutoTranslator-BepIn-5x-{VERSION}.zip from [releases](../../releases).
+ 2. Extract directly into the game directory, such that the plugin dlls are placed in BepInEx folder.
+
+The file structure should like like this:
+```
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.Core.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.BepInEx.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.RuntimeHooker.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/XUnity.RuntimeHooker.Core.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/ExIni.dll
+{GameDirectory}/BepInEx/plugins/XUnity.AutoTranslator/Translators/{Translator}.dll
+{GameDirectory}/BepInEx/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
+```
+
 ### IPA Plugin
 REQUIRES: [IPA plugin manager](https://github.com/Eusth/IPA) (follow its installation instructions first!).
 
@@ -57,6 +87,8 @@ The file structure should like like this
 {GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.Core.dll
 {GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.IPA.dll
 {GameDirectory}/Plugins/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
+{GameDirectory}/Plugins/XUnity.RuntimeHooker.dll
+{GameDirectory}/Plugins/XUnity.RuntimeHooker.Core.dll
 {GameDirectory}/Plugins/0Harmony.dll
 {GameDirectory}/Plugins/ExIni.dll
 {GameDirectory}/Plugins/Translators/{Translator}.dll
@@ -74,6 +106,8 @@ The file structure should like like this
 {GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.Core.dll
 {GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.UnityInjector.dll
 {GameDirectory}/UnityInjector/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
+{GameDirectory}/UnityInjector/XUnity.RuntimeHooker.dll
+{GameDirectory}/UnityInjector/XUnity.RuntimeHooker.Core.dll
 {GameDirectory}/UnityInjector/0Harmony.dll
 {GameDirectory}/UnityInjector/Config/Translators/{Translator}.dll
 {GameDirectory}/UnityInjector/Config/Translation/AnyTranslationFile.txt (these files will be auto generated by plugin!)
@@ -103,6 +137,8 @@ The file structure should like like this
 {GameDirectory}/{GameExeName}_Data/Managed/ReiPatcher.exe
 {GameDirectory}/{GameExeName}_Data/Managed/XUnity.AutoTranslator.Plugin.Core.dll
 {GameDirectory}/{GameExeName}_Data/Managed/XUnity.AutoTranslator.Plugin.ExtProtocol.dll
+{GameDirectory}/{GameExeName}_Data/Managed/XUnity.RuntimeHooker.dll
+{GameDirectory}/{GameExeName}_Data/Managed/XUnity.RuntimeHooker.Core.dll
 {GameDirectory}/{GameExeName}_Data/Managed/0Harmony.dll
 {GameDirectory}/{GameExeName}_Data/Managed/ExIni.dll
 {GameDirectory}/AutoTranslator/Translators/{Translator}.dll
@@ -113,7 +149,6 @@ The file structure should like like this
 The following key inputs are mapped:
  * ALT + 0: Toggle XUnity AutoTranslator UI. (That's a zero, not an O)
  * ALT + T: Alternate between translated and untranslated versions of all texts provided by this plugin.
- * ALT + D: Dump untranslated texts (if no endpoint is configured)
  * ALT + R: Reload translation files. Useful if you change the text and texture files on the fly. Not guaranteed to work for all textures.
  * ALT + U: Manual hooking. The default hooks wont always pick up texts. This will attempt to make lookups manually.
  * ALT + F: If OverrideFont is configured, will toggle between overridden and default font.
@@ -159,7 +194,7 @@ The plugin employs the following spam prevention mechanisms:
  1. When it sees a new text, it will always wait one second before it queues a translation request, to check if that same text changes. It will not send out any request until the text has not changed for 1 second. (Utage (VN Game Engine) is an exception here, as those texts may come from a resource lookup)
  2. It will never send out more than 8000 requests (max 200 characters each (configurable)) during a single game session.
  3. It will never send out more than 1 request at a time (no concurrency!).
- 4. If it detects an increasing number of queued translations (3500), the plugin will shutdown.
+ 4. If it detects an increasing number of queued translations (4000), the plugin will shutdown.
  5. If the service returns no result for five consecutive requests, the plugin will shutdown.
  6. If the plugin detects that the game queues translations every frame, the plugin will shutdown after 90 frames.
  7. If the plugin detects text that "scrolls" into place, the plugin will shutdown. This is detected by inspecting all requests that are queued for translation. ((1) will genenerally prevent this from happening)
@@ -181,16 +216,6 @@ The following text frameworks are supported.
  * [TextMeshPro](http://digitalnativestudios.com/textmeshpro/docs/)
  * [Utage (VN Game Engine)](http://madnesslabo.net/utage/?lang=en)
 
-## Plugin Frameworks
-The mod can be installed into the following Plugin Managers:
- * [BepInEx](https://github.com/bbepis/BepInEx) (preferred approach)
- * [IPA](https://github.com/Eusth/IPA)
- * UnityInjector
-
-Installation instructions for all methods can be found below.
-
-Additionally it can be installed without a dependency on a plugin manager through ReiPatcher. However, this approach is not recommended if you use one of the above mentioned Plugin Managers!
-
 ## Configuration
 The default configuration file, looks as such:
 
@@ -383,7 +408,6 @@ In this context, the `Translation\_AutoGeneratedTranslations.{lang}.txt` (Output
 ## Regarding Redistribution
 Redistributing this plugin for various games is absolutely encouraged. However, if you do so, please keep the following in mind:
  * **Distribute the _AutoGeneratedTranslations.{lang}.txt file along with the redistribution with as many translations as possible to ensure the online translator is hit as little as possible.**
- * **Do not redistribute the mod with the configuration option `EnableIMGUI=True`.**
  * **Test your redistribution with logging/console enabled to ensure the game does not exhibit undesirable behaviour such as spamming the endpoints.**
  * Ensure you keep the plugin up-to-date, as much as reasonably possible.
  * If you use image loading feature, make sure you read [this section](#texture-translation).

+ 11 - 0
XUnity.AutoTranslator.sln

@@ -83,6 +83,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XUnity.RuntimeHooker.Consol
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XUnity.RuntimeHooker.Benchmark", "test\XUnity.RuntimeHooker.Benchmark\XUnity.RuntimeHooker.Benchmark.csproj", "{E2F50278-9134-4DC8-9C50-4C0A52063A89}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XUnity.AutoTranslator.Plugin.BepIn-5x", "src\XUnity.AutoTranslator.Plugin.BepIn-5x\XUnity.AutoTranslator.Plugin.BepIn-5x.csproj", "{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -291,6 +293,14 @@ Global
 		{E2F50278-9134-4DC8-9C50-4C0A52063A89}.Release|Any CPU.Build.0 = Release|Any CPU
 		{E2F50278-9134-4DC8-9C50-4C0A52063A89}.Release|x86.ActiveCfg = Release|Any CPU
 		{E2F50278-9134-4DC8-9C50-4C0A52063A89}.Release|x86.Build.0 = Release|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Debug|x86.Build.0 = Debug|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Release|Any CPU.Build.0 = Release|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Release|x86.ActiveCfg = Release|Any CPU
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -322,6 +332,7 @@ Global
 		{BE7ED338-106C-4CC6-BA19-8ADB2534326A} = {0F9B38FC-4E57-4B83-AF0B-0993B8470823}
 		{20E57B16-F3C7-4D80-88FC-09D6C5A4CCC3} = {2A4A3DDF-338C-40C0-8E26-2A810BAAADD6}
 		{E2F50278-9134-4DC8-9C50-4C0A52063A89} = {2A4A3DDF-338C-40C0-8E26-2A810BAAADD6}
+		{ADCCF172-7D31-42C6-B9D4-1779EAC8B403} = {0F9B38FC-4E57-4B83-AF0B-0993B8470823}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {EE803FED-4447-4D19-B3D6-88C56E8DFCCA}

BIN
libs/BepInEx 5.0/BepInEx.Harmony.dll


+ 51 - 0
libs/BepInEx 5.0/BepInEx.Harmony.xml

@@ -0,0 +1,51 @@
+<?xml version="1.0"?>
+<doc>
+    <assembly>
+        <name>BepInEx.Harmony</name>
+    </assembly>
+    <members>
+        <member name="T:BepInEx.Harmony.ParameterByRefAttribute">
+            <summary>
+            Specifies the indices of parameters that are ByRef.
+            </summary>
+        </member>
+        <member name="P:BepInEx.Harmony.ParameterByRefAttribute.ParameterIndices">
+            <summary>
+            The indices of parameters that are ByRef.
+            </summary>
+        </member>
+        <member name="M:BepInEx.Harmony.ParameterByRefAttribute.#ctor(System.Int32[])">
+            <param name="parameterIndices">The indices of parameters that are ByRef.</param>
+        </member>
+        <member name="T:BepInEx.Harmony.HarmonyWrapper">
+            <summary>
+            A wrapper for Harmony based operations.
+            </summary>
+        </member>
+        <member name="P:BepInEx.Harmony.HarmonyWrapper.DefaultInstance">
+            <summary>
+            The HarmonyInstance that is used when none is specified.
+            </summary>
+        </member>
+        <member name="M:BepInEx.Harmony.HarmonyWrapper.PatchAll(System.Type,Harmony.HarmonyInstance)">
+            <summary>
+            Applies all patches specified in the type.
+            </summary>
+            <param name="type">The type to scan.</param>
+            <param name="harmonyInstance">The HarmonyInstance to use.</param>
+        </member>
+        <member name="M:BepInEx.Harmony.HarmonyWrapper.PatchAll(System.Reflection.Assembly,Harmony.HarmonyInstance)">
+            <summary>
+            Applies all patches specified in the assembly.
+            </summary>
+            <param name="assembly">The assembly to scan.</param>
+            <param name="harmonyInstance">The HarmonyInstance to use.</param>
+        </member>
+        <member name="M:BepInEx.Harmony.HarmonyWrapper.PatchAll(Harmony.HarmonyInstance)">
+            <summary>
+            Applies all patches specified in the calling assembly.
+            </summary>
+            <param name="harmonyInstance">The HarmonyInstance to use.</param>
+        </member>
+    </members>
+</doc>

BIN
libs/BepInEx 5.0/BepInEx.Preloader.dll


BIN
libs/BepInEx 5.0/BepInEx.dll


+ 7 - 1
src/Translators/BingTranslate/BingTranslateEndpoint.cs

@@ -200,7 +200,11 @@ namespace BingTranslate
          var iterator = response.GetSupportedEnumerator();
          while( iterator.MoveNext() ) yield return iterator.Current;
 
-         InspectResponse( response );
+         if( response.IsTimedOut )
+         {
+            XuaLogger.Current.Warn( "A timeout error occurred while setting up BingTranslate IG. Proceeding without..." );
+            yield break;
+         }
 
          // failure
          if( response.Error != null )
@@ -216,6 +220,8 @@ namespace BingTranslate
             yield break;
          }
 
+         InspectResponse( response );
+
          try
          {
             var html = response.Data;

+ 7 - 1
src/Translators/GoogleTranslate/GoogleTranslateEndpoint.cs

@@ -249,7 +249,11 @@ namespace GoogleTranslate
          var iterator = response.GetSupportedEnumerator();
          while( iterator.MoveNext() ) yield return iterator.Current;
 
-         InspectResponse( response );
+         if( response.IsTimedOut )
+         {
+            XuaLogger.Current.Warn( "A timeout error occurred while setting up GoogleTranslate TKK. Using fallback TKK values instead." );
+            yield break;
+         }
 
          // failure
          if( response.Error != null )
@@ -265,6 +269,8 @@ namespace GoogleTranslate
             yield break;
          }
 
+         InspectResponse( response );
+
          try
          {
             var html = response.Data;

+ 1 - 1
src/XUnity.AutoTranslator.Patcher/Patcher.cs

@@ -29,7 +29,7 @@ namespace XUnity.AutoTranslator.Patcher
       {
          get
          {
-            return "3.1.0";
+            return "3.2.0";
          }
       }
 

+ 69 - 0
src/XUnity.AutoTranslator.Plugin.BepIn-5x/AutoTranslatorPlugin.cs

@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using BepInEx;
+using ExIni;
+using XUnity.AutoTranslator.Plugin.Core;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Constants;
+
+namespace XUnity.AutoTranslator.Plugin.BepIn_5x
+{
+   [BepInPlugin( GUID: PluginData.Identifier, Name: PluginData.Name, Version: PluginData.Version )]
+   public class AutoTranslatorPlugin : BaseUnityPlugin, IPluginEnvironment
+   {
+      private IniFile _file;
+      private string _configPath;
+
+      public AutoTranslatorPlugin()
+      {
+         PluginPath = @"BepInEx\plugins\XUnity.AutoTranslator";
+         ConfigPath = @"BepInEx\config";
+         TranslationPath = @"BepInEx";
+
+         _configPath = Path.Combine( ConfigPath, "AutoTranslatorConfig.ini" );
+         XuaLogger.Current = new BepInLogger();
+      }
+
+      public IniFile Preferences
+      {
+         get
+         {
+            return ( _file ?? ( _file = ReloadConfig() ) );
+         }
+      }
+
+      public string PluginPath { get; }
+
+      public string ConfigPath { get; }
+
+      public string TranslationPath { get; }
+
+      public IniFile ReloadConfig()
+      {
+         if( !File.Exists( _configPath ) )
+         {
+            return ( _file ?? new IniFile() );
+         }
+         IniFile ini = IniFile.FromFile( _configPath );
+         if( _file == null )
+         {
+            return ( _file = ini );
+         }
+         _file.Merge( ini );
+         return _file;
+      }
+
+      public void SaveConfig()
+      {
+         _file.Save( _configPath );
+      }
+
+      void Awake()
+      {
+         PluginLoader.LoadWithConfig( this );
+      }
+   }
+}

+ 42 - 0
src/XUnity.AutoTranslator.Plugin.BepIn-5x/BepInLogger.cs

@@ -0,0 +1,42 @@
+using BepInEx.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using XUnity.AutoTranslator.Plugin.Core;
+using XUnity.AutoTranslator.Plugin.Core.Constants;
+
+namespace XUnity.AutoTranslator.Plugin.BepIn_5x
+{
+   public class BepInLogger : XuaLogger
+   {
+      private readonly ManualLogSource _logger = Logger.CreateLogSource( "XUnity.AutoTranslator " + PluginData.Version );
+
+      public BepInLogger()
+      {
+         RespectSettings = false;
+      }
+
+      protected override void Log( Core.LogLevel level, string message )
+      {
+         _logger.Log( Convert( level ), message );
+      }
+
+      public BepInEx.Logging.LogLevel Convert( Core.LogLevel level )
+      {
+         switch( level )
+         {
+            case Core.LogLevel.Debug:
+               return BepInEx.Logging.LogLevel.Debug;
+            case Core.LogLevel.Info:
+               return BepInEx.Logging.LogLevel.Info;
+            case Core.LogLevel.Warn:
+               return BepInEx.Logging.LogLevel.Warning;
+            case Core.LogLevel.Error:
+               return BepInEx.Logging.LogLevel.Error;
+            default:
+               return BepInEx.Logging.LogLevel.None;
+         }
+      }
+   }
+}

+ 37 - 0
src/XUnity.AutoTranslator.Plugin.BepIn-5x/XUnity.AutoTranslator.Plugin.BepIn-5x.csproj

@@ -0,0 +1,37 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net35</TargetFramework>
+    <Version>3.2.0</Version>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\XUnity.AutoTranslator.Plugin.Core\XUnity.AutoTranslator.Plugin.Core.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Reference Include="BepInEx">
+      <HintPath>..\..\libs\BepInEx 5.0\BepInEx.dll</HintPath>
+    </Reference>
+    <Reference Include="ExIni">
+      <HintPath>..\..\libs\ExIni.dll</HintPath>
+    </Reference>
+    <Reference Include="UnityEngine">
+      <HintPath>..\..\libs\UnityEngine.dll</HintPath>
+    </Reference>
+    <Reference Include="UnityEngine.UI">
+      <HintPath>..\..\libs\UnityEngine.UI.dll</HintPath>
+    </Reference>
+  </ItemGroup>
+
+  <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+    <GetAssemblyIdentity AssemblyFiles="$(TargetPath)">
+      <Output TaskParameter="Assemblies" ItemName="Targets" />
+    </GetAssemblyIdentity>
+    <ItemGroup>
+      <VersionNumber Include="$([System.Text.RegularExpressions.Regex]::Replace(&quot;%(Targets.Version)&quot;, &quot;^(.+?)(\.0+)$&quot;, &quot;$1&quot;))" />
+    </ItemGroup>
+    <Exec Command="if $(ConfigurationName) == Release (&#xD;&#xA;   for %%f in (&quot;$(SolutionDir)dist\Translators\*&quot;) do XCOPY /Y /I &quot;%%f&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\Translators\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)ExIni.dll&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.Core.dll&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.ExtProtocol.dll&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.Core.dll&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.dll&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)$(TargetName)$(TargetExt)&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\XUnity.AutoTranslator\&quot;&#xD;&#xA;   COPY /Y &quot;$(SolutionDir)README.md&quot; &quot;$(SolutionDir)dist\BepIn-5x\BepInEx\plugins\README (AutoTranslator).md&quot;&#xD;&#xA;   powershell Compress-Archive -Path '$(SolutionDir)dist\BepIn-5x\BepInEx' -DestinationPath '$(SolutionDir)dist\BepIn-5x\XUnity.AutoTranslator-BepIn-5x-@(VersionNumber).zip' -Force)&#xD;&#xA;)" />
+  </Target>
+
+</Project>

+ 8 - 3
src/XUnity.AutoTranslator.Plugin.BepIn/AutoTranslatorPlugin.cs

@@ -16,11 +16,12 @@ namespace XUnity.AutoTranslator.Plugin.BepIn
    {
       private IniFile _file;
       private string _configPath;
+      private readonly string _dataPath;
 
       public AutoTranslatorPlugin()
       {
-         DataPath = "BepInEx";
-         _configPath = Path.Combine( DataPath, "AutoTranslatorConfig.ini" );
+         _dataPath = "BepInEx";
+         _configPath = Path.Combine( _dataPath, "AutoTranslatorConfig.ini" );
          XuaLogger.Current = new BepInLogger();
       }
 
@@ -32,7 +33,11 @@ namespace XUnity.AutoTranslator.Plugin.BepIn
          }
       }
 
-      public string DataPath { get; }
+      public string PluginPath => _dataPath;
+
+      public string TranslationPath => _dataPath;
+
+      public string ConfigPath => _dataPath;
 
       public IniFile ReloadConfig()
       {

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.BepIn/XUnity.AutoTranslator.Plugin.BepIn.csproj

@@ -2,7 +2,7 @@
 
    <PropertyGroup>
       <TargetFramework>net35</TargetFramework>
-      <Version>3.1.0</Version>
+      <Version>3.2.0</Version>
    </PropertyGroup>
 
    <ItemGroup>

File diff suppressed because it is too large
+ 186 - 735
src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs


+ 106 - 87
src/XUnity.AutoTranslator.Plugin.Core/Configuration/Settings.cs

@@ -16,14 +16,14 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
    {
       // cannot be changed
       public static readonly int MaxFailuresForSameTextPerEndpoint = 3;
-      public static readonly string PluginFolder = "Translators";
+      public static readonly string TranslatorsFolder = "Translators";
       public static readonly int MaxMaxCharactersPerTranslation = 500;
       public static readonly string DefaultLanguage = "en";
       public static readonly string DefaultFromLanguage = "ja";
       public static readonly string EnglishLanguage = "en";
       public static readonly string Romaji = "romaji";
       public static readonly int MaxErrors = 5;
-      public static readonly float ClipboardDebounceTime = 1f;
+      public static readonly float ClipboardDebounceTime = 1.250f;
       public static readonly int MaxTranslationsBeforeShutdown = 8000;
       public static readonly int MaxUnstartedJobs = 4000;
       public static readonly float IncreaseBatchOperationsEvery = 30;
@@ -33,13 +33,15 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
       public static readonly int MaximumConsecutiveSecondsTranslated = 60;
       public static bool UsesWhitespaceBetweenWords = false;
       public static string ApplicationName;
-      public static float Timeout = 50.0f;
+      public static float Timeout = 150.0f;
+
+      public static bool SimulateError = false;
+      public static bool SimulateDelayedError = false;
 
       public static bool InvokeEvents = true;
       public static Action<object> RemakeTextData = null;
 
       public static bool IsShutdown = false;
-      public static bool IsShutdownFatal = false;
       public static int TranslationCount = 0;
       public static int MaxAvailableBatchOperations = 50;
 
@@ -100,104 +102,121 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
       public static bool CopyToClipboard;
       public static int MaxClipboardCopyCharacters;
 
-      public static void SetEndpoint( string id )
-      {
-         ServiceEndpoint = id;
-         PluginEnvironment.Current.Preferences[ "Service" ][ "Endpoint" ].Value = id;
-         PluginEnvironment.Current.SaveConfig();
-      }
-
       public static void Configure()
       {
          try
          {
             ApplicationName = Path.GetFileNameWithoutExtension( ApplicationInformation.StartupPath );
+
+            ServiceEndpoint = PluginEnvironment.Current.Preferences.GetOrDefault( "Service", "Endpoint", KnownTranslateEndpointNames.GoogleTranslate );
+
+            Language = string.Intern( PluginEnvironment.Current.Preferences.GetOrDefault( "General", "Language", DefaultLanguage ) );
+            FromLanguage = string.Intern( PluginEnvironment.Current.Preferences.GetOrDefault( "General", "FromLanguage", DefaultFromLanguage ) );
+
+            TranslationDirectory = PluginEnvironment.Current.Preferences.GetOrDefault( "Files", "Directory", "Translation" );
+            OutputFile = PluginEnvironment.Current.Preferences.GetOrDefault( "Files", "OutputFile", @"Translation\_AutoGeneratedTranslations.{lang}.txt" );
+
+            EnableIMGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableIMGUI", false );
+            EnableUGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableUGUI", true );
+            EnableNGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableNGUI", true );
+            EnableTextMeshPro = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableTextMeshPro", true );
+            AllowPluginHookOverride = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "AllowPluginHookOverride", true );
+
+            Delay = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "Delay", 0f );
+            MaxCharactersPerTranslation = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MaxCharactersPerTranslation", 200 );
+            IgnoreWhitespaceInDialogue = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreWhitespaceInDialogue", true );
+            IgnoreWhitespaceInNGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreWhitespaceInNGUI", true );
+            MinDialogueChars = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MinDialogueChars", 20 );
+            ForceSplitTextAfterCharacters = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceSplitTextAfterCharacters", 0 );
+            CopyToClipboard = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "CopyToClipboard", false );
+            MaxClipboardCopyCharacters = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MaxClipboardCopyCharacters", 1000 );
+            EnableUIResizing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableUIResizing", true );
+            EnableBatching = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableBatching", true );
+            TrimAllText = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TrimAllText", ClrTypes.AdvEngine == null );
+            UseStaticTranslations = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "UseStaticTranslations", true );
+            OverrideFont = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "OverrideFont", string.Empty );
+            ResizeUILineSpacingScale = PluginEnvironment.Current.Preferences.GetOrDefault<float?>( "Behaviour", "ResizeUILineSpacingScale", null );
+            ForceUIResizing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceUIResizing", false );
+            IgnoreTextStartingWith = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreTextStartingWith", "\\u180e;" )
+               ?.Split( new[] { ';' }, StringSplitOptions.RemoveEmptyEntries ).Select( x => JsonHelper.Unescape( x ) ).ToArray() ?? new string[ 0 ];
+            TextGetterCompatibilityMode = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TextGetterCompatibilityMode", false );
+            GameLogTextPaths = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "GameLogTextPaths", string.Empty )
+               ?.Split( new[] { ';' }, StringSplitOptions.RemoveEmptyEntries ).ToHashSet() ?? new HashSet<string>();
+            GameLogTextPaths.RemoveWhere( x => !x.StartsWith( "/" ) ); // clean up to ensure no 'empty' entries
+            WhitespaceRemovalStrategy = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "WhitespaceRemovalStrategy", WhitespaceHandlingStrategy.TrimPerNewline );
+            RomajiPostProcessing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "RomajiPostProcessing", TextPostProcessing.ReplaceMacronWithCircumflex | TextPostProcessing.RemoveApostrophes );
+            TranslationPostProcessing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TranslationPostProcessing", TextPostProcessing.ReplaceMacronWithCircumflex );
+            EnableExperimentalHooks = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableExperimentalHooks", false );
+            ForceExperimentalHooks = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceExperimentalHooks", false );
+
+            TextureDirectory = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "TextureDirectory", @"Translation\Texture" );
+            EnableTextureTranslation = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureTranslation", false );
+            EnableTextureDumping = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureDumping", false );
+            EnableTextureToggling = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureToggling", false );
+            EnableTextureScanOnSceneLoad = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureScanOnSceneLoad", false );
+            EnableSpriteRendererHooking = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableSpriteRendererHooking", false );
+            LoadUnmodifiedTextures = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "LoadUnmodifiedTextures", false );
+            TextureHashGenerationStrategy = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "TextureHashGenerationStrategy", TextureHashGenerationStrategy.FromImageName );
+
+            if( MaxCharactersPerTranslation > MaxMaxCharactersPerTranslation )
+            {
+               PluginEnvironment.Current.Preferences[ "Behaviour" ][ "MaxCharactersPerTranslation" ].Value = MaxMaxCharactersPerTranslation.ToString( CultureInfo.InvariantCulture );
+               MaxCharactersPerTranslation = MaxMaxCharactersPerTranslation;
+            }
+
+            UserAgent = PluginEnvironment.Current.Preferences.GetOrDefault( "Http", "UserAgent", string.Empty );
+            DisableCertificateValidation = PluginEnvironment.Current.Preferences.GetOrDefault( "Http", "DisableCertificateValidation", GetInitialDisableCertificateChecks() );
+
+            EnablePrintHierarchy = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnablePrintHierarchy", false );
+            EnableConsole = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnableConsole", false );
+            EnableDebugLogs = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnableLog", false );
+
+            EnableMigrations = PluginEnvironment.Current.Preferences.GetOrDefault( "Migrations", "Enable", true );
+            MigrationsTag = PluginEnvironment.Current.Preferences.GetOrDefault( "Migrations", "Tag", string.Empty );
+
+            AutoTranslationsFilePath = Path.Combine( PluginEnvironment.Current.TranslationPath, OutputFile.Replace( "{lang}", Language ) ).Replace( "/", "\\" ).Parameterize();
+            UsesWhitespaceBetweenWords = LanguageHelper.RequiresWhitespaceUponLineMerging( FromLanguage );
+
+
+
+
+            if( EnableMigrations )
+            {
+               Migrate();
+            }
+
+            // update tag
+            MigrationsTag = PluginEnvironment.Current.Preferences[ "Migrations" ][ "Tag" ].Value = PluginData.Version;
+
+            Save();
          }
          catch( Exception e )
          {
-            ApplicationName = "Unknown";
-            XuaLogger.Current.Error( e, "An error occurred while getting application name." );
-         }
-
+            XuaLogger.Current.Error( e, "An error occurred during configuration. Shutting plugin down." );
 
-         ServiceEndpoint = PluginEnvironment.Current.Preferences.GetOrDefault( "Service", "Endpoint", KnownTranslateEndpointNames.GoogleTranslate );
-
-         Language = string.Intern( PluginEnvironment.Current.Preferences.GetOrDefault( "General", "Language", DefaultLanguage ) );
-         FromLanguage = string.Intern( PluginEnvironment.Current.Preferences.GetOrDefault( "General", "FromLanguage", DefaultFromLanguage ) );
-
-         TranslationDirectory = PluginEnvironment.Current.Preferences.GetOrDefault( "Files", "Directory", "Translation" );
-         OutputFile = PluginEnvironment.Current.Preferences.GetOrDefault( "Files", "OutputFile", @"Translation\_AutoGeneratedTranslations.{lang}.txt" );
-
-         EnableIMGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableIMGUI", false );
-         EnableUGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableUGUI", true );
-         EnableNGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableNGUI", true );
-         EnableTextMeshPro = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "EnableTextMeshPro", true );
-         AllowPluginHookOverride = PluginEnvironment.Current.Preferences.GetOrDefault( "TextFrameworks", "AllowPluginHookOverride", true );
-
-         Delay = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "Delay", 0f );
-         MaxCharactersPerTranslation = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MaxCharactersPerTranslation", 200 );
-         IgnoreWhitespaceInDialogue = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreWhitespaceInDialogue", true );
-         IgnoreWhitespaceInNGUI = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreWhitespaceInNGUI", true );
-         MinDialogueChars = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MinDialogueChars", 20 );
-         ForceSplitTextAfterCharacters = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceSplitTextAfterCharacters", 0 );
-         CopyToClipboard = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "CopyToClipboard", false );
-         MaxClipboardCopyCharacters = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "MaxClipboardCopyCharacters", 450 );
-         EnableUIResizing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableUIResizing", true );
-         EnableBatching = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableBatching", true );
-         TrimAllText = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TrimAllText", ClrTypes.AdvEngine == null );
-         UseStaticTranslations = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "UseStaticTranslations", true );
-         OverrideFont = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "OverrideFont", string.Empty );
-         ResizeUILineSpacingScale = PluginEnvironment.Current.Preferences.GetOrDefault<float?>( "Behaviour", "ResizeUILineSpacingScale", null );
-         ForceUIResizing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceUIResizing", false );
-         IgnoreTextStartingWith = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "IgnoreTextStartingWith", "\\u180e;" )
-            ?.Split( new[] { ';' }, StringSplitOptions.RemoveEmptyEntries ).Select( x => JsonHelper.Unescape( x ) ).ToArray() ?? new string[ 0 ];
-         TextGetterCompatibilityMode = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TextGetterCompatibilityMode", false );
-         GameLogTextPaths = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "GameLogTextPaths", string.Empty )
-            ?.Split( new[] { ';' }, StringSplitOptions.RemoveEmptyEntries ).ToHashSet() ?? new HashSet<string>();
-         GameLogTextPaths.RemoveWhere( x => !x.StartsWith( "/" ) ); // clean up to ensure no 'empty' entries
-         WhitespaceRemovalStrategy = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "WhitespaceRemovalStrategy", WhitespaceHandlingStrategy.TrimPerNewline );
-         RomajiPostProcessing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "RomajiPostProcessing", TextPostProcessing.ReplaceMacronWithCircumflex | TextPostProcessing.RemoveApostrophes );
-         TranslationPostProcessing = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "TranslationPostProcessing", TextPostProcessing.ReplaceMacronWithCircumflex );
-         EnableExperimentalHooks = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "EnableExperimentalHooks", false );
-         ForceExperimentalHooks = PluginEnvironment.Current.Preferences.GetOrDefault( "Behaviour", "ForceExperimentalHooks", false );
-
-         TextureDirectory = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "TextureDirectory", @"Translation\Texture" );
-         EnableTextureTranslation = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureTranslation", false );
-         EnableTextureDumping = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureDumping", false );
-         EnableTextureToggling = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureToggling", false );
-         EnableTextureScanOnSceneLoad = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableTextureScanOnSceneLoad", false );
-         EnableSpriteRendererHooking = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "EnableSpriteRendererHooking", false );
-         LoadUnmodifiedTextures = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "LoadUnmodifiedTextures", false );
-         TextureHashGenerationStrategy = PluginEnvironment.Current.Preferences.GetOrDefault( "Texture", "TextureHashGenerationStrategy", TextureHashGenerationStrategy.FromImageName );
-         
-         if( MaxCharactersPerTranslation > MaxMaxCharactersPerTranslation )
-         {
-            PluginEnvironment.Current.Preferences[ "Behaviour" ][ "MaxCharactersPerTranslation" ].Value = MaxMaxCharactersPerTranslation.ToString( CultureInfo.InvariantCulture );
-            MaxCharactersPerTranslation = MaxMaxCharactersPerTranslation;
+            IsShutdown = true;
          }
+      }
 
-         UserAgent = PluginEnvironment.Current.Preferences.GetOrDefault( "Http", "UserAgent", string.Empty );
-         DisableCertificateValidation = PluginEnvironment.Current.Preferences.GetOrDefault( "Http", "DisableCertificateValidation", GetInitialDisableCertificateChecks() );
-
-         EnablePrintHierarchy = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnablePrintHierarchy", false );
-         EnableConsole = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnableConsole", false );
-         EnableDebugLogs = PluginEnvironment.Current.Preferences.GetOrDefault( "Debug", "EnableLog", false );
-
-         EnableMigrations = PluginEnvironment.Current.Preferences.GetOrDefault( "Migrations", "Enable", true );
-         MigrationsTag = PluginEnvironment.Current.Preferences.GetOrDefault( "Migrations", "Tag", string.Empty );
+      public static void SetEndpoint( string id )
+      {
+         id = id ?? string.Empty;
 
-         AutoTranslationsFilePath = Path.Combine( PluginEnvironment.Current.DataPath, OutputFile.Replace( "{lang}", Language ) ).Replace( "/", "\\" ).Parameterize();
-         UsesWhitespaceBetweenWords = LanguageHelper.RequiresWhitespaceUponLineMerging( FromLanguage );
+         ServiceEndpoint = id;
+         PluginEnvironment.Current.Preferences[ "Service" ][ "Endpoint" ].Value = id;
+         Save();
+      }
 
-         if( EnableMigrations )
+      internal static void Save()
+      {
+         try
          {
-            Migrate();
+            PluginEnvironment.Current.SaveConfig();
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred during while saving configuration." );
          }
-
-         // update tag
-         MigrationsTag = PluginEnvironment.Current.Preferences[ "Migrations" ][ "Tag" ].Value = PluginData.Version;
-
-         PluginEnvironment.Current.SaveConfig();
       }
       
       private static void Migrate()

+ 3 - 0
src/XUnity.AutoTranslator.Plugin.Core/Constants/ClrTypes.cs

@@ -25,6 +25,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Constants
       public static readonly Type UIInput = FindType( "UIInput" );
 
       // Unity
+      public static readonly Type GUIContent = FindType( "UnityEngine.GUIContent" );
       public static readonly Type WWW = FindType( "UnityEngine.WWW" );
       public static readonly Type InputField = FindType( "UnityEngine.UI.InputField" );
       public static readonly Type Text = FindType( "UnityEngine.UI.Text" );
@@ -56,6 +57,8 @@ namespace XUnity.AutoTranslator.Plugin.Core.Constants
       public static readonly Type AdvDataManager = FindType( "Utage.AdvDataManager" );
       public static readonly Type AdvScenarioData = FindType( "Utage.AdvScenarioData" );
       public static readonly Type AdvScenarioLabelData = FindType( "Utage.AdvScenarioLabelData" );
+      public static readonly Type DicingTextures = FindType( "Utage.DicingTextures" );
+      public static readonly Type DicingImage = FindType( "Utage.DicingImage" );
 
       // Live2D
       public static readonly Type CubismRenderer = FindType( "Live2D.Cubism.Rendering.CubismRenderer" );

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/Constants/PluginData.cs

@@ -23,6 +23,6 @@ namespace XUnity.AutoTranslator.Plugin.Core.Constants
       /// <summary>
       /// Gets the version of the plugin.
       /// </summary>
-      public const string Version = "3.1.0";
+      public const string Version = "3.2.0";
    }
 }

+ 4 - 4
src/XUnity.AutoTranslator.Plugin.Core/Constants/UserAgents.cs

@@ -15,21 +15,21 @@ namespace XUnity.AutoTranslator.Plugin.Core.Constants
       /// <summary>
       /// Latest Chrome Win10 user-agent as of 2019-02-09.
       /// </summary>
-      public static readonly string Chrome_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36";
+      public static readonly string Chrome_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36";
 
       /// <summary>
       /// Latest Chrome Win7 user-agent as of 2019-02-09.
       /// </summary>
-      public static readonly string Chrome_Win7_Latest = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36";
+      public static readonly string Chrome_Win7_Latest = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36";
 
       /// <summary>
       /// Latest Firefox Win10 user-agent as of 2019-02-09.
       /// </summary>
-      public static readonly string Firefox_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:64.0) Gecko/20100101 Firefox/64.0";
+      public static readonly string Firefox_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0";
 
       /// <summary>
       /// Latest Edge Win10 user-agent as of 2019-02-09.
       /// </summary>
-      public static readonly string Edge_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134";
+      public static readonly string Edge_Win10_Latest = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763";
    }
 }

+ 23 - 12
src/XUnity.AutoTranslator.Plugin.Core/Debugging/DebugConsole.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Text;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
 
 namespace XUnity.AutoTranslator.Plugin.Core.Debugging
 {
@@ -12,23 +13,33 @@ namespace XUnity.AutoTranslator.Plugin.Core.Debugging
 
       public static void Enable()
       {
-         var oldConsoleOut = Kernel32.GetStdHandle( -11 );
-         if( !Kernel32.AllocConsole() ) return;
+         if( Settings.EnableConsole )
+         {
+            try
+            {
+               var oldConsoleOut = Kernel32.GetStdHandle( -11 );
+               if( !Kernel32.AllocConsole() ) return;
 
-         _consoleOut = Kernel32.CreateFile( "CONOUT$", 0x40000000, 2, IntPtr.Zero, 3, 0, IntPtr.Zero );
-         if( !Kernel32.SetStdHandle( -11, _consoleOut ) ) return;
+               _consoleOut = Kernel32.CreateFile( "CONOUT$", 0x40000000, 2, IntPtr.Zero, 3, 0, IntPtr.Zero );
+               if( !Kernel32.SetStdHandle( -11, _consoleOut ) ) return;
 
-         Stream stream = Console.OpenStandardOutput();
-         StreamWriter writer = new StreamWriter( stream, Encoding.Default );
-         writer.AutoFlush = true;
+               Stream stream = Console.OpenStandardOutput();
+               StreamWriter writer = new StreamWriter( stream, Encoding.Default );
+               writer.AutoFlush = true;
 
-         Console.SetOut( writer );
-         Console.SetError( writer );
+               Console.SetOut( writer );
+               Console.SetError( writer );
 
-         uint shiftjisCodePage = 932;
+               uint shiftjisCodePage = 932;
 
-         Kernel32.SetConsoleOutputCP( shiftjisCodePage );
-         Console.OutputEncoding = ConsoleEncoding.GetEncoding( shiftjisCodePage );
+               Kernel32.SetConsoleOutputCP( shiftjisCodePage );
+               Console.OutputEncoding = ConsoleEncoding.GetEncoding( shiftjisCodePage );
+            }
+            catch( Exception e )
+            {
+               XuaLogger.Current.Error( e, "An error occurred during while enabling console." );
+            }
+         }
       }
    }
 }

+ 5 - 7
src/XUnity.AutoTranslator.Plugin.Core/DefaultPluginEnvironment.cs

@@ -27,13 +27,11 @@ namespace XUnity.AutoTranslator.Plugin.Core
          }
       }
 
-      public string DataPath
-      {
-         get
-         {
-            return _dataFolder;
-         }
-      }
+      public string PluginPath => _dataFolder;
+
+      public string TranslationPath => _dataFolder;
+
+      public string ConfigPath => _dataFolder;
 
       public void SaveConfig()
       {

+ 0 - 101
src/XUnity.AutoTranslator.Plugin.Core/Endpoints/ConfiguredEndpoint.cs

@@ -1,101 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using UnityEngine;
-using XUnity.AutoTranslator.Plugin.Core.Configuration;
-
-namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
-{
-   internal class ConfiguredEndpoint
-   {
-      private int _ongoingTranslations;
-      private Dictionary<string, byte> _failedTranslations;
-
-      public ConfiguredEndpoint( ITranslateEndpoint endpoint, Exception error )
-      {
-         Endpoint = endpoint;
-         Error = error;
-         _ongoingTranslations = 0;
-         _failedTranslations = new Dictionary<string, byte>();
-      }
-
-      public ITranslateEndpoint Endpoint { get; }
-
-      public Exception Error { get; }
-
-      public bool IsBusy => _ongoingTranslations >= Endpoint.MaxConcurrency;
-
-      public bool CanTranslate( string untranslatedText )
-      {
-         if( _failedTranslations.TryGetValue( untranslatedText, out var count ) )
-         {
-            return count < Settings.MaxFailuresForSameTextPerEndpoint;
-         }
-         return true;
-      }
-
-      public void RegisterTranslationFailureFor( string untranslatedText )
-      {
-         byte count;
-         if( !_failedTranslations.TryGetValue( untranslatedText, out count ) )
-         {
-            count = 1;
-         }
-         else
-         {
-            count++;
-         }
-
-         _failedTranslations[ untranslatedText ] = count;
-      }
-
-      public IEnumerator Translate( string[] untranslatedTexts, string from, string to, Action<string[]> success, Action<string, Exception> failure )
-      {
-         var startTime = Time.realtimeSinceStartup;
-         var context = new TranslationContext( untranslatedTexts, from, to, success, failure );
-         _ongoingTranslations++;
-
-         try
-         {
-            bool ok = false;
-            var iterator = Endpoint.Translate( context );
-            if( iterator != null )
-            {
-               TryMe: try
-               {
-                  ok = iterator.MoveNext();
-
-                  // check for timeout
-                  var now = Time.realtimeSinceStartup;
-                  if( now - startTime > Settings.Timeout )
-                  {
-                     ok = false;
-                     context.FailWithoutThrowing( $"Timeout occurred during translation (took more than {Settings.Timeout} seconds)", null );
-                  }
-               }
-               catch( TranslationContextException )
-               {
-                  ok = false;
-               }
-               catch( Exception e )
-               {
-                  ok = false;
-                  context.FailWithoutThrowing( "Error occurred during translation.", e );
-               }
-
-               if( ok )
-               {
-                  yield return iterator.Current;
-                  goto TryMe;
-               }
-            }
-         }
-         finally
-         {
-            _ongoingTranslations--;
-
-            context.FailIfNotCompleted();
-         }
-      }
-   }
-}

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/Endpoints/InitializationContext.cs

@@ -33,7 +33,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
       /// </summary>
       public string DestinationLanguage { get; }
 
-      public string PluginDirectory => PluginEnvironment.Current.DataPath;
+      public string PluginDirectory => PluginEnvironment.Current.PluginPath;
 
       public void DisableCertificateChecksFor( params string[] hosts )
       {

+ 0 - 66
src/XUnity.AutoTranslator.Plugin.Core/Endpoints/KnownTranslateEndpoints.cs

@@ -1,66 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using UnityEngine;
-using XUnity.AutoTranslator.Plugin.Core.Configuration;
-using XUnity.AutoTranslator.Plugin.Core.Constants;
-using XUnity.AutoTranslator.Plugin.Core.Web;
-
-namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
-{
-   internal static class KnownTranslateEndpoints
-   {
-      public static List<ConfiguredEndpoint> CreateEndpoints( GameObject go, InitializationContext context )
-      {
-         var endpoints = new List<ConfiguredEndpoint>();
-
-         // could dynamically load types from other assemblies...
-         //var integratedTypes = AssemblyLoader.GetAllTypesOf<ITranslateEndpoint>( typeof( KnownEndpoints ).Assembly );
-         var pluginFolder = Path.Combine( PluginEnvironment.Current.DataPath, Settings.PluginFolder );
-         var dynamicTypes = AssemblyLoader.GetAllTypesOf<ITranslateEndpoint>( pluginFolder );
-
-         foreach( var type in dynamicTypes )
-         {
-            AddEndpoint( go, context, endpoints, type );
-         }
-
-         return endpoints;
-      }
-
-      private static void AddEndpoint( GameObject go, InitializationContext context, List<ConfiguredEndpoint> endpoints, Type type )
-      {
-         ITranslateEndpoint endpoint;
-         try
-         {
-            if( typeof( MonoBehaviour ).IsAssignableFrom( type ) )
-            {
-               // allow implementing plugins to hook into Unity lifecycle
-               endpoint = (ITranslateEndpoint)go.AddComponent( type );
-               UnityEngine.Object.DontDestroyOnLoad( (UnityEngine.Object)endpoint );
-            }
-            else
-            {
-               // or... just use any old object
-               endpoint = (ITranslateEndpoint)Activator.CreateInstance( type );
-            }
-         }
-         catch( Exception e )
-         {
-            XuaLogger.Current.Error( e, "Could not instantiate class: " + type.Name );
-            return;
-         }
-
-         try
-         {
-            endpoint.Initialize( context );
-            endpoints.Add( new ConfiguredEndpoint( endpoint, null ) );
-         }
-         catch( Exception e )
-         {
-            endpoints.Add( new ConfiguredEndpoint( endpoint, e ) );
-         }
-      }
-   }
-}

+ 565 - 0
src/XUnity.AutoTranslator.Plugin.Core/Endpoints/TranslationEndpointManager.cs

@@ -0,0 +1,565 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Extensions;
+using XUnity.AutoTranslator.Plugin.Core.Parsing;
+using XUnity.AutoTranslator.Plugin.Core.Utilities;
+
+namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
+{
+   internal class TranslationEndpointManager
+   {
+      private Dictionary<string, byte> _failedTranslations;
+      private Dictionary<string, TranslationJob> _unstartedJobs;
+      private Dictionary<string, TranslationJob> _ongoingJobs;
+
+      private int _ongoingTranslations;
+
+      // used for prototyping
+      private Dictionary<string, string> _translations;
+      private Dictionary<string, string> _reverseTranslations;
+
+      public TranslationEndpointManager( ITranslateEndpoint endpoint, Exception error )
+      {
+         Endpoint = endpoint;
+         Error = error;
+         _ongoingTranslations = 0;
+
+         _failedTranslations = new Dictionary<string, byte>();
+         _unstartedJobs = new Dictionary<string, TranslationJob>();
+         _ongoingJobs = new Dictionary<string, TranslationJob>();
+
+         _translations = new Dictionary<string, string>();
+         _reverseTranslations = new Dictionary<string, string>();
+
+         HasBatchLogicFailed = false;
+         AvailableBatchOperations = Settings.MaxAvailableBatchOperations;
+      }
+
+      public TranslationManager Manager { get; set; }
+
+      public ITranslateEndpoint Endpoint { get; }
+
+      public Exception Error { get; }
+
+      public bool IsBusy => _ongoingTranslations >= Endpoint.MaxConcurrency;
+
+      public bool HasBatchLogicFailed { get; set; }
+
+      public int AvailableBatchOperations { get; set; }
+
+      public int ConsecutiveErrors { get; set; }
+
+      public bool CanBatch => Endpoint.MaxTranslationsPerRequest > 1 && _unstartedJobs.Count > 1 && !HasBatchLogicFailed && AvailableBatchOperations > 0;
+
+      public bool HasUnstartedBatch => _unstartedJobs.Count > 0 && AvailableBatchOperations > 0;
+
+      public bool HasUnstartedJob => _unstartedJobs.Count > 0;
+
+      public bool HasFailedDueToConsecutiveErrors => ConsecutiveErrors >= Settings.MaxErrors;
+
+      public bool TryGetTranslation( TranslationKey key, out string value )
+      {
+         return TryGetTranslation( key.GetDictionaryLookupKey(), out value );
+      }
+
+      public bool TryGetTranslation( string key, out string value )
+      {
+         return _translations.TryGetValue( key, out value );
+      }
+
+      private void AddTranslation( string key, string value )
+      {
+         _translations[ key ] = value;
+         _reverseTranslations[ value ] = key;
+      }
+
+      private void QueueNewTranslationForDisk( string key, string value )
+      {
+         // FIXME: Implement
+      }
+
+      public void AddTranslationToCache( TranslationKey key, string value )
+      {
+         // UNRELEASED: Not included in current release
+         //AddTranslationToCache( key.GetDictionaryLookupKey(), value );
+      }
+
+      public void AddTranslationToCache( string key, string value )
+      {
+         // UNRELEASED: Not included in current release
+         //if( !HasTranslated( key ) )
+         //{
+         //   AddTranslation( key, value );
+         //   QueueNewTranslationForDisk( key, value );
+         //}
+      }
+
+      public bool IsTranslatable( string text )
+      {
+         return LanguageHelper.IsTranslatable( text ) && !IsTranslation( text );
+      }
+
+      private bool IsTranslation( string translation )
+      {
+         return _reverseTranslations.ContainsKey( translation );
+      }
+
+      private bool HasTranslated( string key )
+      {
+         return _translations.ContainsKey( key );
+      }
+
+      public void HandleNextBatch()
+      {
+         try
+         {
+            var kvps = _unstartedJobs.Take( Endpoint.MaxTranslationsPerRequest ).ToList();
+            var untranslatedTexts = new List<string>();
+            var jobs = new List<TranslationJob>();
+
+            foreach( var kvp in kvps )
+            {
+               var key = kvp.Key;
+               var job = kvp.Value;
+               _unstartedJobs.Remove( key );
+               Manager.UnstartedTranslations--;
+
+               var untranslatedText = job.Key.GetDictionaryLookupKey();
+               if( CanTranslate( untranslatedText ) )
+               {
+                  jobs.Add( job );
+                  untranslatedTexts.Add( untranslatedText );
+                  _ongoingJobs[ key ] = job;
+                  Manager.OngoingTranslations++;
+               }
+               else
+               {
+                  XuaLogger.Current.Warn( $"Dequeued: '{untranslatedText}' because the current endpoint has already failed this translation 3 times." );
+                  job.State = TranslationJobState.Failed;
+                  job.ErrorMessage = "The endpoint failed to perform this translation 3 or more times.";
+
+                  Manager.InvokeJobFailed( job );
+               }
+            }
+
+            if( jobs.Count > 0 )
+            {
+               AvailableBatchOperations--;
+               var jobsArray = jobs.ToArray();
+
+               foreach( var untranslatedText in untranslatedTexts )
+               {
+                  XuaLogger.Current.Debug( "Started: '" + untranslatedText + "'" );
+               }
+               CoroutineHelper.Start(
+                  Translate(
+                     untranslatedTexts.ToArray(),
+                     Settings.FromLanguage,
+                     Settings.Language,
+                     translatedText => OnBatchTranslationCompleted( jobsArray, translatedText ),
+                     ( msg, e ) => OnTranslationFailed( jobsArray, msg, e ) ) );
+            }
+         }
+         finally
+         {
+            if( _unstartedJobs.Count == 0 )
+            {
+               Manager.UnscheduleUnstartedJobs( this );
+            }
+         }
+      }
+
+
+      public void HandleNextJob()
+      {
+         try
+         {
+            var kvp = _unstartedJobs.FirstOrDefault();
+
+            var key = kvp.Key;
+            var job = kvp.Value;
+            _unstartedJobs.Remove( key );
+            Manager.UnstartedTranslations--;
+
+            var untranslatedText = job.Key.GetDictionaryLookupKey();
+            if( CanTranslate( untranslatedText ) )
+            {
+               _ongoingJobs[ key ] = job;
+               Manager.OngoingTranslations++;
+
+               XuaLogger.Current.Debug( "Started: '" + untranslatedText + "'" );
+               CoroutineHelper.Start(
+                  Translate(
+                     new[] { untranslatedText },
+                     Settings.FromLanguage,
+                     Settings.Language,
+                     translatedText => OnSingleTranslationCompleted( job, translatedText ),
+                     ( msg, e ) => OnTranslationFailed( new[] { job }, msg, e ) ) );
+            }
+            else
+            {
+               XuaLogger.Current.Warn( $"Dequeued: '{untranslatedText}' because the current endpoint has already failed this translation 3 times." );
+               job.State = TranslationJobState.Failed;
+               job.ErrorMessage = "The endpoint failed to perform this translation 3 or more times.";
+
+               Manager.InvokeJobFailed( job );
+            }
+         }
+         finally
+         {
+            if( _unstartedJobs.Count == 0 )
+            {
+               Manager.UnscheduleUnstartedJobs( this );
+            }
+         }
+      }
+
+      private void OnBatchTranslationCompleted( TranslationJob[] jobs, string[] translatedTexts )
+      {
+         ConsecutiveErrors = 0;
+
+         var succeeded = jobs.Length == translatedTexts.Length;
+         if( succeeded )
+         {
+            for( int i = 0; i < jobs.Length; i++ )
+            {
+               var job = jobs[ i ];
+               var translatedText = translatedTexts[ i ];
+
+               job.TranslatedText = PostProcessTranslation( job.Key, translatedText );
+               job.State = TranslationJobState.Succeeded;
+
+               RemoveOngoingTranslation( job.Key.GetDictionaryLookupKey() );
+
+               XuaLogger.Current.Info( $"Completed: '{job.Key.GetDictionaryLookupKey()}' => '{job.TranslatedText}'" );
+
+               Manager.InvokeJobCompleted( job );
+            }
+         }
+         else
+         {
+            if( !HasBatchLogicFailed )
+            {
+               CoroutineHelper.Start( EnableBatchingAfterDelay() );
+            }
+
+            HasBatchLogicFailed = true;
+            for( int i = 0; i < jobs.Length; i++ )
+            {
+               var job = jobs[ i ];
+
+               var key = job.Key.GetDictionaryLookupKey();
+               AddUnstartedJob( key, job );
+               RemoveOngoingTranslation( key );
+            }
+
+            XuaLogger.Current.Error( "A batch operation failed. Disabling batching and restarting failed jobs." );
+         }
+      }
+
+      private void OnSingleTranslationCompleted( TranslationJob job, string[] translatedTexts )
+      {
+         var translatedText = translatedTexts[ 0 ];
+
+         ConsecutiveErrors = 0;
+
+         job.TranslatedText = PostProcessTranslation( job.Key, translatedText );
+         job.State = TranslationJobState.Succeeded;
+
+         RemoveOngoingTranslation( job.Key.GetDictionaryLookupKey() );
+
+         XuaLogger.Current.Info( $"Completed: '{job.Key.GetDictionaryLookupKey()}' => '{job.TranslatedText}'" );
+
+         Manager.InvokeJobCompleted( job );
+      }
+
+      private string PostProcessTranslation( TranslationKey key, string translatedText )
+      {
+         var hasTranslation = !string.IsNullOrEmpty( translatedText );
+         if( hasTranslation )
+         {
+            translatedText = key.RepairTemplate( translatedText );
+
+            if( Settings.Language == Settings.Romaji && Settings.RomajiPostProcessing != TextPostProcessing.None )
+            {
+               translatedText = RomanizationHelper.PostProcess( translatedText, Settings.RomajiPostProcessing );
+            }
+            else if( Settings.TranslationPostProcessing != TextPostProcessing.None )
+            {
+               translatedText = RomanizationHelper.PostProcess( translatedText, Settings.TranslationPostProcessing );
+            }
+
+            if( Settings.ForceSplitTextAfterCharacters > 0 )
+            {
+               translatedText = translatedText.SplitToLines( Settings.ForceSplitTextAfterCharacters, '\n', ' ', ' ' );
+            }
+         }
+
+         return translatedText;
+      }
+
+      private void OnTranslationFailed( TranslationJob[] jobs, string error, Exception e )
+      {
+         if( e == null )
+         {
+            XuaLogger.Current.Error( error );
+         }
+         else
+         {
+            XuaLogger.Current.Error( e, error );
+         }
+
+         if( jobs.Length == 1 )
+         {
+            foreach( var job in jobs )
+            {
+               var untranslatedText = job.Key.GetDictionaryLookupKey();
+               job.State = TranslationJobState.Failed;
+               job.ErrorMessage = error;
+
+               RemoveOngoingTranslation( untranslatedText );
+
+               RegisterTranslationFailureFor( untranslatedText );
+
+               Manager.InvokeJobFailed( job );
+            }
+         }
+         else
+         {
+            if( !HasBatchLogicFailed )
+            {
+               CoroutineHelper.Start( EnableBatchingAfterDelay() );
+            }
+
+            HasBatchLogicFailed = true;
+            for( int i = 0; i < jobs.Length; i++ )
+            {
+               var job = jobs[ i ];
+
+               var key = job.Key.GetDictionaryLookupKey();
+               AddUnstartedJob( key, job );
+               RemoveOngoingTranslation( key );
+            }
+
+            XuaLogger.Current.Error( "A batch operation failed. Disabling batching and restarting failed jobs." );
+         }
+
+         if( !HasFailedDueToConsecutiveErrors )
+         {
+            ConsecutiveErrors++;
+
+            if( HasFailedDueToConsecutiveErrors )
+            {
+               XuaLogger.Current.Error( $"{Settings.MaxErrors} or more consecutive errors occurred. Shutting down translator endpoint." );
+
+               ClearAllJobs();
+            }
+         }
+      }
+
+      private IEnumerator EnableBatchingAfterDelay()
+      {
+         yield return new WaitForSeconds( 240 );
+
+         HasBatchLogicFailed = false;
+
+         XuaLogger.Current.Info( "Re-enabled batching." );
+      }
+
+      public bool EnqueueTranslation( object ui, TranslationKey key, TranslationResult translationResult, ParserTranslationContext context )
+      {
+         var lookupKey = key.GetDictionaryLookupKey();
+
+         var added = AssociateWithExistingJobIfPossible( ui, lookupKey, translationResult, context );
+         if( added )
+         {
+            return false;
+         }
+
+         var checkOtherEndpoints = translationResult == null;
+         if( checkOtherEndpoints )
+         {
+            var endpoints = Manager.ConfiguredEndpoints;
+            var len = endpoints.Count;
+            for( int i = 0; i < len; i++ )
+            {
+               var endpoint = endpoints[ i ];
+               if( endpoint == this ) continue;
+
+               added = endpoint.AssociateWithExistingJobIfPossible( ui, lookupKey, translationResult, context );
+               if( added )
+               {
+                  return false;
+               }
+            }
+         }
+
+         XuaLogger.Current.Debug( "Queued: '" + lookupKey + "'" );
+
+         var saveResultGlobally = checkOtherEndpoints;
+         var newJob = new TranslationJob( this, key, saveResultGlobally );
+         newJob.Associate( ui, translationResult, context );
+
+         return AddUnstartedJob( lookupKey, newJob );
+      }
+
+      private bool AssociateWithExistingJobIfPossible( object ui, string key, TranslationResult translationResult, ParserTranslationContext context )
+      {
+         if( _unstartedJobs.TryGetValue( key, out TranslationJob unstartedJob ) )
+         {
+            unstartedJob.Associate( ui, translationResult, context );
+            return true;
+         }
+
+         if( _ongoingJobs.TryGetValue( key, out TranslationJob ongoingJob ) )
+         {
+            ongoingJob.Associate( ui, translationResult, context );
+            return true;
+         }
+
+         return false;
+      }
+
+      private bool AddUnstartedJob( string key, TranslationJob job )
+      {
+         if( !_unstartedJobs.ContainsKey( key ) )
+         {
+            int countBefore = _unstartedJobs.Count;
+
+            _unstartedJobs.Add( key, job );
+            Manager.UnstartedTranslations++;
+
+            if( countBefore == 0 )
+            {
+               Manager.ScheduleUnstartedJobs( this );
+            }
+
+            return true;
+         }
+         return false;
+      }
+
+      private void RemoveOngoingTranslation( string key )
+      {
+         if( _ongoingJobs.Remove( key ) )
+         {
+            Manager.OngoingTranslations--;
+         }
+      }
+
+      public void ClearAllJobs()
+      {
+         var ongoingCount = _ongoingJobs.Count;
+         var unstartedCount = _unstartedJobs.Count;
+
+         var unstartedJobs = _unstartedJobs.ToList();
+
+         _ongoingJobs.Clear();
+         _unstartedJobs.Clear();
+
+         foreach( var job in unstartedJobs )
+         {
+            XuaLogger.Current.Warn( $"Dequeued: '{job.Key}'" );
+            job.Value.State = TranslationJobState.Failed;
+            job.Value.ErrorMessage = "Translation failed because all jobs on endpoint was cleared.";
+
+            Manager.InvokeJobFailed( job.Value );
+         }
+
+         Manager.OngoingTranslations -= ongoingCount;
+         Manager.UnstartedTranslations -= unstartedCount;
+
+         Manager.UnscheduleUnstartedJobs( this );
+      }
+
+      private bool CanTranslate( string untranslatedText )
+      {
+         if( _failedTranslations.TryGetValue( untranslatedText, out var count ) )
+         {
+            return count < Settings.MaxFailuresForSameTextPerEndpoint;
+         }
+         return true;
+      }
+
+      private void RegisterTranslationFailureFor( string untranslatedText )
+      {
+         byte count;
+         if( !_failedTranslations.TryGetValue( untranslatedText, out count ) )
+         {
+            count = 1;
+         }
+         else
+         {
+            count++;
+         }
+
+         _failedTranslations[ untranslatedText ] = count;
+      }
+
+      public IEnumerator Translate( string[] untranslatedTexts, string from, string to, Action<string[]> success, Action<string, Exception> failure )
+      {
+         var startTime = Time.realtimeSinceStartup;
+         var context = new TranslationContext( untranslatedTexts, from, to, success, failure );
+         _ongoingTranslations++;
+
+         try
+         {
+            if( Settings.SimulateDelayedError )
+            {
+               yield return new WaitForSeconds( 1 );
+
+               context.FailWithoutThrowing( "Simulating delayed error. Press CTRL+ALT+NP8 to disable!", null );
+            }
+            else if( Settings.SimulateError )
+            {
+               context.FailWithoutThrowing( "Simulating error. Press CTRL+ALT+NP9 to disable!", null );
+            }
+            else
+            {
+               bool ok = false;
+               var iterator = Endpoint.Translate( context );
+               if( iterator != null )
+               {
+               TryMe: try
+                  {
+                     ok = iterator.MoveNext();
+
+                     // check for timeout
+                     var now = Time.realtimeSinceStartup;
+                     if( now - startTime > Settings.Timeout )
+                     {
+                        ok = false;
+                        context.FailWithoutThrowing( $"Timeout occurred during translation (took more than {Settings.Timeout} seconds)", null );
+                     }
+                  }
+                  catch( TranslationContextException )
+                  {
+                     ok = false;
+                  }
+                  catch( Exception e )
+                  {
+                     ok = false;
+                     context.FailWithoutThrowing( "Error occurred during translation.", e );
+                  }
+
+                  if( ok )
+                  {
+                     yield return iterator.Current;
+                     goto TryMe;
+                  }
+               }
+            }
+         }
+         finally
+         {
+            _ongoingTranslations--;
+
+            context.FailIfNotCompleted();
+         }
+      }
+   }
+}

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/Extensions/HarmonyInstanceExtensions.cs

@@ -121,7 +121,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Extensions
             }
             else
             {
-               XuaLogger.Current.Warn( e, $"An error occurred while patching a property/method. Failing hook: '{type.Name}'." );
+               XuaLogger.Current.Warn( e, $"An error occurred while patching property/method. Failing hook: '{type.Name}'." );
             }
          }
       }

+ 20 - 1
src/XUnity.AutoTranslator.Plugin.Core/Extensions/TextComponentExtensions.cs

@@ -13,6 +13,12 @@ namespace XUnity.AutoTranslator.Plugin.Core.Extensions
 {
    internal static class TextComponentExtensions
    {
+      //private static readonly GUIContent[] TemporaryGUIContents = ClrTypes.GUIContent
+      //   .GetFields( BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic )
+      //   .Where( x => x.DeclaringType == typeof( GUIContent ) && ( x.Name == "s_Text" || x.Name == "s_TextImage" ) )
+      //   .Select( x => (GUIContent)x.GetValue( null ) )
+      //   .ToArray();
+
       private static readonly string RichTextPropertyName = "richText";
       private static readonly string TextPropertyName = "text";
 
@@ -46,7 +52,20 @@ namespace XUnity.AutoTranslator.Plugin.Core.Extensions
          if( ui == null ) return false;
 
          // shortcircuit for spammy component, to avoid reflective calls
-         if( ui is GUIContent ) return false;
+         if( ui is GUIContent )
+         {
+            return false;
+
+            //var len = TemporaryGUIContents.Length;
+            //for( int i = 0; i < len; i++ )
+            //{
+            //   if( ReferenceEquals( ui, TemporaryGUIContents[ i ] ) )
+            //   {
+            //      return false;
+            //   }
+            //}
+            //return true;
+         }
 
          var type = ui.GetType();
 

+ 106 - 0
src/XUnity.AutoTranslator.Plugin.Core/Hooks/IMGUIHooks.cs

@@ -5,6 +5,7 @@ using System.Reflection;
 using System.Text;
 using Harmony;
 using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Constants;
 using static UnityEngine.GUI;
 
 namespace XUnity.AutoTranslator.Plugin.Core.Hooks.IMGUI
@@ -14,6 +15,11 @@ namespace XUnity.AutoTranslator.Plugin.Core.Hooks.IMGUI
       public static bool HooksOverriden = false;
 
       public static readonly Type[] All = new[] {
+         //typeof( GUIContent_text_Hook ),
+         //typeof( GUIContent_Temp_Hook1 ),
+         //typeof( GUIContent_Temp_Hook2 ),
+         //typeof( GUIContent_Temp_Hook3 ),
+
          typeof( GUI_BeginGroup_Hook ),
          typeof( GUI_Box_Hook ),
          typeof( GUI_DoRepeatButton_Hook ),
@@ -27,6 +33,106 @@ namespace XUnity.AutoTranslator.Plugin.Core.Hooks.IMGUI
       };
    }
 
+
+   //[Harmony, HarmonyPriority( Priority.Last )]
+   //internal static class GUIContent_text_Hook
+   //{
+   //   static bool Prepare( HarmonyInstance instance )
+   //   {
+   //      return ClrTypes.GUIContent != null;
+   //   }
+
+   //   static MethodBase TargetMethod( HarmonyInstance instance )
+   //   {
+   //      return AccessTools.Property( ClrTypes.GUIContent, "text" )?.GetSetMethod();
+   //   }
+
+   //   static void Postfix( GUIContent __instance )
+   //   {
+   //      if( !IMGUIHooks.HooksOverriden )
+   //      {
+   //         AutoTranslationPlugin.Current.Hook_TextChanged( __instance, false );
+   //      }
+   //   }
+   //}
+
+   //[Harmony, HarmonyPriority( Priority.Last )]
+   //internal static class GUIContent_Temp_Hook1
+   //{
+   //   static bool Prepare( HarmonyInstance instance )
+   //   {
+   //      return ClrTypes.GUIContent != null;
+   //   }
+
+   //   static MethodBase TargetMethod( HarmonyInstance instance )
+   //   {
+   //      return AccessTools.Method( ClrTypes.GUIContent, "Temp", new[] { typeof( string ) } );
+   //   }
+
+   //   static void Postfix( GUIContent __result )
+   //   {
+   //      if( !IMGUIHooks.HooksOverriden )
+   //      {
+   //         AutoTranslationPlugin.Current.Hook_TextChanged( __result, false );
+   //      }
+   //   }
+   //}
+
+   //[Harmony, HarmonyPriority( Priority.Last )]
+   //internal static class GUIContent_Temp_Hook2
+   //{
+   //   static bool Prepare( HarmonyInstance instance )
+   //   {
+   //      return ClrTypes.GUIContent != null;
+   //   }
+
+   //   static MethodBase TargetMethod( HarmonyInstance instance )
+   //   {
+   //      return AccessTools.Method( ClrTypes.GUIContent, "Temp", new[] { typeof( string ), typeof( string ) } );
+   //   }
+
+   //   static void Postfix( GUIContent __result )
+   //   {
+   //      if( !IMGUIHooks.HooksOverriden )
+   //      {
+   //         AutoTranslationPlugin.Current.Hook_TextChanged( __result, false );
+   //      }
+   //   }
+   //}
+
+   //[Harmony, HarmonyPriority( Priority.Last )]
+   //internal static class GUIContent_Temp_Hook3
+   //{
+   //   static bool Prepare( HarmonyInstance instance )
+   //   {
+   //      return ClrTypes.GUIContent != null;
+   //   }
+
+   //   static MethodBase TargetMethod( HarmonyInstance instance )
+   //   {
+   //      return AccessTools.Method( ClrTypes.GUIContent, "Temp", new[] { typeof( string ), typeof( Texture ) } );
+   //   }
+
+   //   static void Postfix( GUIContent __result )
+   //   {
+   //      if( !IMGUIHooks.HooksOverriden )
+   //      {
+   //         AutoTranslationPlugin.Current.Hook_TextChanged( __result, false );
+   //      }
+   //   }
+   //}
+
+
+
+
+
+
+
+
+
+
+
+
    [Harmony, HarmonyPriority( Priority.Last )]
    internal static class GUI_BeginGroup_Hook
    {

+ 22 - 0
src/XUnity.AutoTranslator.Plugin.Core/Hooks/ImageHooks.cs

@@ -42,9 +42,31 @@ namespace XUnity.AutoTranslator.Plugin.Core.Hooks
          typeof( UITexture_material_Hook ),
          typeof( UIPanel_clipTexture_Hook ),
          typeof( UIRect_OnInit_Hook ),
+
+         // Utage
+         typeof( DicingTextures_GetTexture_Hook ),
       };
    }
 
+   [Harmony]
+   internal static class DicingTextures_GetTexture_Hook
+   {
+      static bool Prepare( HarmonyInstance instance )
+      {
+         return ClrTypes.DicingTextures != null;
+      }
+
+      static MethodBase TargetMethod( HarmonyInstance instance )
+      {
+         return AccessTools.Method( ClrTypes.DicingTextures, "GetTexture", new[] { typeof( string ) } );
+      }
+
+      public static void Postfix( object __instance, Texture2D __result )
+      {
+         AutoTranslationPlugin.Current.Hook_ImageChanged( __result, false );
+      }
+   }
+
    [Harmony]
    internal static class Sprite_texture_Hook
    {

+ 11 - 1
src/XUnity.AutoTranslator.Plugin.Core/IPluginEnvironment.cs

@@ -17,7 +17,17 @@ namespace XUnity.AutoTranslator.Plugin.Core
       /// <summary>
       /// Gets the path the plugin is located at.
       /// </summary>
-      string DataPath { get; }
+      string PluginPath { get; }
+
+      /// <summary>
+      /// Gets or sets the path representing the root of the translations.
+      /// </summary>
+      string TranslationPath { get; }
+
+      /// <summary>
+      /// Gets or sets the path representing the config directory.
+      /// </summary>
+      string ConfigPath { get; }
 
       /// <summary>
       /// Gets the preferences file.

+ 129 - 0
src/XUnity.AutoTranslator.Plugin.Core/ITranslator.cs

@@ -0,0 +1,129 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+using XUnity.AutoTranslator.Plugin.Core.Utilities;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   static class AutoTranslator
+   {
+      public static ITranslator Default => AutoTranslationPlugin.Current;
+   }
+
+   interface ITranslator
+   {
+      TranslationResult Translate( TranslationEndpointManager endpoint, string untranslatedText );
+   }
+
+   class TranslationResult : IEnumerator
+   {
+      public event Action<string> Completed;
+      public event Action<string> Error;
+
+      internal bool IsCompleted { get; private set; }
+
+      public string TranslatedText { get; private set; }
+
+      public string ErrorMessage { get; private set; }
+
+      public void SetCompleted( string translatedText, bool delay )
+      {
+         if( !IsCompleted )
+         {
+            IsCompleted = true;
+
+            if( delay )
+            {
+               CoroutineHelper.Start( SetCompletedAfterDelay( translatedText ) );
+            }
+            else
+            {
+               SetCompletedInternal( translatedText );
+            }
+         }
+      }
+
+      public void SetEmptyResponse( bool delay )
+      {
+         SetError( "Received empty response.", delay );
+      }
+
+      public void SetErrorWithMessage( string errorMessage, bool delay )
+      {
+         SetError( errorMessage, delay );
+      }
+
+      private void SetError( string errorMessage, bool delay )
+      {
+         if( !IsCompleted )
+         {
+            IsCompleted = true;
+
+            if( delay )
+            {
+               CoroutineHelper.Start( SetErrorAfterDelay( errorMessage ) );
+            }
+            else
+            {
+               SetErrorInternal( errorMessage );
+            }
+         }
+      }
+
+      private IEnumerator SetErrorAfterDelay( string errorMessage )
+      {
+         yield return null;
+
+         SetErrorInternal( errorMessage );
+      }
+
+      private void SetErrorInternal( string errorMessage )
+      {
+         ErrorMessage = errorMessage;
+
+         try
+         {
+            Error?.Invoke( errorMessage );
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while notifying of translation failure." );
+         }
+      }
+
+      private IEnumerator SetCompletedAfterDelay( string translatedText )
+      {
+         yield return null;
+
+         SetCompletedInternal( translatedText );
+      }
+
+      private void SetCompletedInternal( string translatedText )
+      {
+         TranslatedText = translatedText;
+
+         try
+         {
+            Completed?.Invoke( translatedText );
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while notifying of translation completion." );
+         }
+      }
+
+      public object Current => null;
+
+      public bool MoveNext()
+      {
+         return !IsCompleted;
+      }
+
+      public void Reset()
+      {
+      }
+   }
+}

+ 29 - 0
src/XUnity.AutoTranslator.Plugin.Core/ParserTranslationContext.cs

@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using System.Linq;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+using XUnity.AutoTranslator.Plugin.Core.Parsing;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   internal class ParserTranslationContext
+   {
+      public ParserTranslationContext( object component, TranslationEndpointManager endpoint, TranslationResult translationResult, ParserResult result )
+      {
+         Jobs = new HashSet<TranslationJob>();
+         Component = component;
+         Result = result;
+         Endpoint = endpoint;
+         TranslationResult = translationResult;
+      }
+
+      public ParserResult Result { get; private set; }
+
+      public HashSet<TranslationJob> Jobs { get; private set; }
+
+      public TranslationResult TranslationResult { get; private set; }
+
+      public object Component { get; private set; }
+
+      public TranslationEndpointManager Endpoint { get; private set; }
+   }
+}

+ 228 - 0
src/XUnity.AutoTranslator.Plugin.Core/SpamChecker.cs

@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Extensions;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   class SpamChecker
+   {
+      private int[] _currentTranslationsQueuedPerSecondRollingWindow = new int[ Settings.TranslationQueueWatchWindow ];
+      private float? _timeExceededThreshold;
+      private float _translationsQueuedPerSecond;
+
+      private string[] _previouslyQueuedText = new string[ Settings.PreviousTextStaggerCount ];
+      private int _staggerTextCursor = 0;
+      private int _concurrentStaggers = 0;
+      private int _lastStaggerCheckFrame = -1;
+
+      private int _frameForLastQueuedTranslation = -1;
+      private int _consecutiveFramesTranslated = 0;
+
+      private int _secondForQueuedTranslation = -1;
+      private int _consecutiveSecondsTranslated = 0;
+
+      private TranslationManager _translationManager;
+
+      public SpamChecker( TranslationManager translationManager )
+      {
+         _translationManager = translationManager;
+      }
+
+      public void PerformChecks( string untranslatedText )
+      {
+         CheckStaggerText( untranslatedText );
+         CheckConsecutiveFrames();
+         CheckConsecutiveSeconds();
+         CheckThresholds();
+      }
+
+      public void Update()
+      {
+         PeriodicResetFrameCheck();
+         ResetThresholdTimerIfRequired();
+      }
+
+      private void CheckConsecutiveSeconds()
+      {
+         var currentSecond = (int)Time.time;
+         var lastSecond = currentSecond - 1;
+
+         if( lastSecond == _secondForQueuedTranslation )
+         {
+            // we also queued something last frame, lets increment our counter
+            _consecutiveSecondsTranslated++;
+
+            if( _consecutiveSecondsTranslated > Settings.MaximumConsecutiveSecondsTranslated )
+            {
+               // Shutdown, this wont be tolerated!!!
+               _translationManager.ClearAllJobs();
+
+               Settings.IsShutdown = true;
+               XuaLogger.Current.Error( $"SPAM DETECTED: Translations were queued every second for more than {Settings.MaximumConsecutiveSecondsTranslated} consecutive seconds. Shutting down plugin." );
+            }
+
+         }
+         else if( currentSecond == _secondForQueuedTranslation )
+         {
+            // do nothing, there may be multiple translations per frame, that wont increase this counter
+         }
+         else
+         {
+            // but if multiple Update frames has passed, we will reset the counter
+            _consecutiveSecondsTranslated = 0;
+         }
+
+         _secondForQueuedTranslation = currentSecond;
+      }
+
+      private void CheckConsecutiveFrames()
+      {
+         var currentFrame = Time.frameCount;
+         var lastFrame = currentFrame - 1;
+
+         if( lastFrame == _frameForLastQueuedTranslation )
+         {
+            // we also queued something last frame, lets increment our counter
+            _consecutiveFramesTranslated++;
+
+            if( _consecutiveFramesTranslated > Settings.MaximumConsecutiveFramesTranslated )
+            {
+               // Shutdown, this wont be tolerated!!!
+               _translationManager.ClearAllJobs();
+
+               Settings.IsShutdown = true;
+               XuaLogger.Current.Error( $"SPAM DETECTED: Translations were queued every frame for more than {Settings.MaximumConsecutiveFramesTranslated} consecutive frames. Shutting down plugin." );
+            }
+
+         }
+         else if( currentFrame == _frameForLastQueuedTranslation )
+         {
+            // do nothing, there may be multiple translations per frame, that wont increase this counter
+         }
+         else if( _consecutiveFramesTranslated > 0 )
+         {
+            // but if multiple Update frames has passed, we will reset the counter
+            _consecutiveFramesTranslated--;
+         }
+
+         _frameForLastQueuedTranslation = currentFrame;
+      }
+
+      private void PeriodicResetFrameCheck()
+      {
+         var currentSecond = (int)Time.time;
+         if( currentSecond % 100 == 0 )
+         {
+            _consecutiveFramesTranslated = 0;
+         }
+      }
+
+      private void CheckStaggerText( string untranslatedText )
+      {
+         var currentFrame = Time.frameCount;
+         if( currentFrame != _lastStaggerCheckFrame )
+         {
+            _lastStaggerCheckFrame = currentFrame;
+
+            bool wasProblematic = false;
+
+            for( int i = 0; i < _previouslyQueuedText.Length; i++ )
+            {
+               var previouslyQueuedText = _previouslyQueuedText[ i ];
+
+               if( previouslyQueuedText != null )
+               {
+                  if( untranslatedText.RemindsOf( previouslyQueuedText ) )
+                  {
+                     wasProblematic = true;
+                     break;
+                  }
+
+               }
+            }
+
+            if( wasProblematic )
+            {
+               _concurrentStaggers++;
+               if( _concurrentStaggers > Settings.MaximumStaggers )
+               {
+                  _translationManager.ClearAllJobs();
+
+                  Settings.IsShutdown = true;
+                  XuaLogger.Current.Error( $"SPAM DETECTED: Text that is 'scrolling in' is being translated. Disable that feature. Shutting down plugin." );
+               }
+            }
+            else
+            {
+               _concurrentStaggers = 0;
+            }
+
+            _previouslyQueuedText[ _staggerTextCursor % _previouslyQueuedText.Length ] = untranslatedText;
+            _staggerTextCursor++;
+         }
+      }
+
+      private void CheckThresholds()
+      {
+         if( _translationManager.UnstartedTranslations > Settings.MaxUnstartedJobs )
+         {
+            _translationManager.ClearAllJobs();
+
+            Settings.IsShutdown = true;
+            XuaLogger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxUnstartedJobs} queued for translations due to unknown reasons. Shutting down plugin." );
+         }
+
+         var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
+         var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
+         if( previousIdx != newIdx )
+         {
+            _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
+         }
+         _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ]++;
+
+         var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
+         _translationsQueuedPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
+         if( _translationsQueuedPerSecond > Settings.MaxTranslationsQueuedPerSecond )
+         {
+            if( !_timeExceededThreshold.HasValue )
+            {
+               _timeExceededThreshold = Time.time;
+            }
+
+            if( Time.time - _timeExceededThreshold.Value > Settings.MaxSecondsAboveTranslationThreshold )
+            {
+               _translationManager.ClearAllJobs();
+
+               Settings.IsShutdown = true;
+               XuaLogger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxTranslationsQueuedPerSecond} translations per seconds queued for a {Settings.MaxSecondsAboveTranslationThreshold} second period. Shutting down plugin." );
+            }
+         }
+         else
+         {
+            _timeExceededThreshold = null;
+         }
+      }
+
+      private void ResetThresholdTimerIfRequired()
+      {
+         var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
+         var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
+         if( previousIdx != newIdx )
+         {
+            _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
+         }
+
+         var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
+         _translationsQueuedPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
+
+         if( _translationsQueuedPerSecond <= Settings.MaxTranslationsQueuedPerSecond )
+         {
+            _timeExceededThreshold = null;
+         }
+      }
+   }
+}

+ 223 - 0
src/XUnity.AutoTranslator.Plugin.Core/TextTranslationCache.cs

@@ -0,0 +1,223 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Extensions;
+using XUnity.AutoTranslator.Plugin.Core.Utilities;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   class TextTranslationCache
+   {
+      private static readonly char[][] TranslationSplitters = new char[][] { new char[] { '\t' }, new char[] { '=' } };
+
+      /// <summary>
+      /// All the translations are stored in this dictionary.
+      /// </summary>
+      private Dictionary<string, string> _staticTranslations = new Dictionary<string, string>();
+      private Dictionary<string, string> _translations = new Dictionary<string, string>();
+      private Dictionary<string, string> _reverseTranslations = new Dictionary<string, string>();
+
+      /// <summary>
+      /// These are the new translations that has not yet been persisted to the file system.
+      /// </summary>
+      private object _writeToFileSync = new object();
+      private Dictionary<string, string> _newTranslations = new Dictionary<string, string>();
+
+      public TextTranslationCache()
+      {
+         LoadStaticTranslations();
+      }
+
+      private static IEnumerable<string> GetTranslationFiles()
+      {
+         return Directory.GetFiles( Path.Combine( PluginEnvironment.Current.TranslationPath, Settings.TranslationDirectory ).Parameterize(), $"*.txt", SearchOption.AllDirectories )
+            .Select( x => x.Replace( "/", "\\" ) );
+      }
+
+      internal void LoadTranslationFiles()
+      {
+         try
+         {
+            var startTime = Time.realtimeSinceStartup;
+            lock( _writeToFileSync )
+            {
+               Directory.CreateDirectory( Path.Combine( PluginEnvironment.Current.TranslationPath, Settings.TranslationDirectory ).Parameterize() );
+               Directory.CreateDirectory( Path.GetDirectoryName( Settings.AutoTranslationsFilePath ) );
+
+               var mainTranslationFile = Settings.AutoTranslationsFilePath;
+               LoadTranslationsInFile( mainTranslationFile );
+               foreach( var fullFileName in GetTranslationFiles().Reverse().Except( new[] { mainTranslationFile } ) )
+               {
+                  LoadTranslationsInFile( fullFileName );
+               }
+            }
+            var endTime = Time.realtimeSinceStartup;
+            XuaLogger.Current.Info( $"Loaded text files (took {Math.Round( endTime - startTime, 2 )} seconds)" );
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while loading translations." );
+         }
+      }
+
+      private void LoadTranslationsInFile( string fullFileName )
+      {
+         if( File.Exists( fullFileName ) )
+         {
+            XuaLogger.Current.Debug( $"Loading texts: {fullFileName}." );
+
+            string[] translations = File.ReadAllLines( fullFileName, Encoding.UTF8 );
+            foreach( string translation in translations )
+            {
+               for( int i = 0; i < TranslationSplitters.Length; i++ )
+               {
+                  var splitter = TranslationSplitters[ i ];
+                  string[] kvp = translation.Split( splitter, StringSplitOptions.None );
+                  if( kvp.Length == 2 )
+                  {
+                     string key = TextHelper.Decode( kvp[ 0 ].TrimIfConfigured() );
+                     string value = TextHelper.Decode( kvp[ 1 ] );
+
+                     if( !string.IsNullOrEmpty( key ) && !string.IsNullOrEmpty( value ) && IsTranslatable( key ) )
+                     {
+                        AddTranslation( key, value );
+                        break;
+                     }
+                  }
+               }
+            }
+         }
+      }
+
+      private void LoadStaticTranslations()
+      {
+         if( Settings.UseStaticTranslations && Settings.FromLanguage == Settings.DefaultFromLanguage && Settings.Language == Settings.DefaultLanguage )
+         {
+            var tab = new char[] { '\t' };
+            var equals = new char[] { '=' };
+            var splitters = new char[][] { tab, equals };
+
+            // load static translations from previous titles
+            string[] translations = Properties.Resources.StaticTranslations.Split( new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries );
+            foreach( string translation in translations )
+            {
+               for( int i = 0; i < splitters.Length; i++ )
+               {
+                  var splitter = splitters[ i ];
+                  string[] kvp = translation.Split( splitter, StringSplitOptions.None );
+                  if( kvp.Length >= 2 )
+                  {
+                     string key = TextHelper.Decode( kvp[ 0 ].TrimIfConfigured() );
+                     string value = TextHelper.Decode( kvp[ 1 ].TrimIfConfigured() );
+
+                     if( !string.IsNullOrEmpty( key ) && !string.IsNullOrEmpty( value ) )
+                     {
+                        _staticTranslations[ key ] = value;
+                        break;
+                     }
+                  }
+               }
+            }
+         }
+      }
+
+      internal void SaveNewTranslationsToDisk()
+      {
+         if( _newTranslations.Count > 0 )
+         {
+            lock( _writeToFileSync )
+            {
+               if( _newTranslations.Count > 0 )
+               {
+                  using( var stream = File.Open( Settings.AutoTranslationsFilePath, FileMode.Append, FileAccess.Write ) )
+                  using( var writer = new StreamWriter( stream, Encoding.UTF8 ) )
+                  {
+                     foreach( var kvp in _newTranslations )
+                     {
+                        writer.WriteLine( TextHelper.Encode( kvp.Key ) + '=' + TextHelper.Encode( kvp.Value ) );
+                     }
+                     writer.Flush();
+                  }
+                  _newTranslations.Clear();
+               }
+            }
+         }
+      }
+
+      private bool HasTranslated( string key )
+      {
+         return _translations.ContainsKey( key );
+      }
+
+      private bool IsTranslation( string translation )
+      {
+         return _reverseTranslations.ContainsKey( translation );
+      }
+
+      private void AddTranslation( string key, string value )
+      {
+         _translations[ key ] = value;
+         _reverseTranslations[ value ] = key;
+      }
+
+      private void QueueNewTranslationForDisk( string key, string value )
+      {
+         lock( _writeToFileSync )
+         {
+            _newTranslations[ key ] = value;
+         }
+      }
+
+      internal void AddTranslationToCache( TranslationKey key, string value )
+      {
+         AddTranslationToCache( key.GetDictionaryLookupKey(), value );
+      }
+
+      internal void AddTranslationToCache( string key, string value )
+      {
+         if( !HasTranslated( key ) )
+         {
+            AddTranslation( key, value );
+            QueueNewTranslationForDisk( key, value );
+         }
+      }
+
+      internal bool TryGetTranslation( TranslationKey key, out string value )
+      {
+         return TryGetTranslation( key.GetDictionaryLookupKey(), out value );
+      }
+
+      internal bool TryGetTranslation( string key, out string value )
+      {
+         var result = _translations.TryGetValue( key, out value );
+         if( result )
+         {
+            return result;
+         }
+         else if( _staticTranslations.Count > 0 )
+         {
+            if( _staticTranslations.TryGetValue( key, out value ) )
+            {
+               QueueNewTranslationForDisk( key, value );
+               AddTranslation( key, value );
+               return true;
+            }
+         }
+         return result;
+      }
+
+      internal bool TryGetReverseTranslation( string value, out string key )
+      {
+         return _reverseTranslations.TryGetValue( value, out key );
+      }
+
+      internal bool IsTranslatable( string text )
+      {
+         return LanguageHelper.IsTranslatable( text ) && !IsTranslation( text );
+      }
+   }
+}

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/TextTranslationInfo.cs

@@ -96,7 +96,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
          if( graphic is Text )
          {
             var ui = (Text)graphic;
-
+            
             // text is likely to be longer than there is space for, simply expand out anyway then
             var componentWidth = ( (RectTransform)ui.transform ).rect.width;
             var quarterScreenSize = Screen.width / 4;

+ 158 - 0
src/XUnity.AutoTranslator.Plugin.Core/TextureTranslationCache.cs

@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Extensions;
+using XUnity.AutoTranslator.Plugin.Core.Utilities;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   class TextureTranslationCache
+   {
+      private Dictionary<string, byte[]> _translatedImages = new Dictionary<string, byte[]>( StringComparer.InvariantCultureIgnoreCase );
+      private HashSet<string> _untranslatedImages = new HashSet<string>();
+
+      public TextureTranslationCache()
+      {
+
+      }
+
+      private IEnumerable<string> GetTextureFiles()
+      {
+         return Directory.GetFiles( Path.Combine( PluginEnvironment.Current.TranslationPath, Settings.TextureDirectory ).Parameterize(), $"*.png", SearchOption.AllDirectories )
+            .Select( x => x.Replace( "/", "\\" ) );
+      }
+
+      public void LoadTranslationFiles()
+      {
+         try
+         {
+            if( Settings.EnableTextureTranslation || Settings.EnableTextureDumping )
+            {
+               var startTime = Time.realtimeSinceStartup;
+
+               _translatedImages.Clear();
+               _untranslatedImages.Clear();
+               Directory.CreateDirectory( Path.Combine( PluginEnvironment.Current.TranslationPath, Settings.TextureDirectory ).Parameterize() );
+               foreach( var fullFileName in GetTextureFiles() )
+               {
+                  RegisterImageFromFile( fullFileName );
+               }
+
+               var endTime = Time.realtimeSinceStartup;
+               XuaLogger.Current.Info( $"Loaded texture files (took {Math.Round( endTime - startTime, 2 )} seconds)" );
+            }
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while loading translations." );
+         }
+      }
+
+      private void RegisterImageFromFile( string fullFileName )
+      {
+         var fileName = Path.GetFileNameWithoutExtension( fullFileName );
+         var startHash = fileName.LastIndexOf( "[" );
+         var endHash = fileName.LastIndexOf( "]" );
+
+         if( endHash > -1 && startHash > -1 && endHash > startHash )
+         {
+            var takeFrom = startHash + 1;
+
+            // load based on whether or not the key is image hashed
+            var parts = fileName.Substring( takeFrom, endHash - takeFrom ).Split( '-' );
+            string key;
+            string originalHash;
+            if( parts.Length == 1 )
+            {
+               key = parts[ 0 ];
+               originalHash = parts[ 0 ];
+            }
+            else if( parts.Length == 2 )
+            {
+               key = parts[ 0 ];
+               originalHash = parts[ 1 ];
+            }
+            else
+            {
+               XuaLogger.Current.Warn( $"Image not loaded (unknown hash): {fullFileName}." );
+               return;
+            }
+
+            var data = File.ReadAllBytes( fullFileName );
+            var currentHash = HashHelper.Compute( data );
+            var isModified = StringComparer.InvariantCultureIgnoreCase.Compare( originalHash, currentHash ) != 0;
+
+            // only load images that someone has modified!
+            if( Settings.LoadUnmodifiedTextures || isModified )
+            {
+               RegisterTranslatedImage( key, data );
+               XuaLogger.Current.Debug( $"Image loaded: {fullFileName}." );
+            }
+            else
+            {
+               RegisterUntranslatedImage( key );
+               XuaLogger.Current.Warn( $"Image not loaded (unmodified): {fullFileName}." );
+            }
+         }
+         else
+         {
+            XuaLogger.Current.Warn( $"Image not loaded (no hash): {fullFileName}." );
+         }
+      }
+
+      internal void RegisterImageFromData( string textureName, string key, byte[] data )
+      {
+         var name = textureName.SanitizeForFileSystem();
+         var root = Path.Combine( PluginEnvironment.Current.TranslationPath, Settings.TextureDirectory ).Parameterize();
+         var originalHash = HashHelper.Compute( data );
+
+         // allow hash and key to be the same; only store one of them then!
+         string fileName;
+         if( key == originalHash )
+         {
+            fileName = name + " [" + key + "].png";
+         }
+         else
+         {
+            fileName = name + " [" + key + "-" + originalHash + "].png";
+         }
+
+         var fullName = Path.Combine( root, fileName );
+         File.WriteAllBytes( fullName, data );
+         XuaLogger.Current.Info( "Dumped texture file: " + fileName );
+
+         if( Settings.LoadUnmodifiedTextures )
+         {
+            RegisterTranslatedImage( key, data );
+         }
+         else
+         {
+            RegisterUntranslatedImage( key );
+         }
+      }
+
+      private void RegisterTranslatedImage( string key, byte[] data )
+      {
+         _translatedImages[ key ] = data;
+      }
+
+      private void RegisterUntranslatedImage( string key )
+      {
+         _untranslatedImages.Add( key );
+      }
+
+      internal bool IsImageRegistered( string key )
+      {
+         return _translatedImages.ContainsKey( key ) || _untranslatedImages.Contains( key );
+      }
+
+      internal bool TryGetTranslatedImage( string key, out byte[] data )
+      {
+         return _translatedImages.TryGetValue( key, out data );
+      }
+   }
+}

+ 0 - 21
src/XUnity.AutoTranslator.Plugin.Core/TranslationContext.cs

@@ -1,21 +0,0 @@
-using System.Collections.Generic;
-using XUnity.AutoTranslator.Plugin.Core.Parsing;
-
-namespace XUnity.AutoTranslator.Plugin.Core
-{
-   internal class TranslationContext
-   {
-      public TranslationContext( object component, ParserResult result )
-      {
-         Jobs = new HashSet<TranslationJob>();
-         Component = component;
-         Result = result;
-      }
-
-      public ParserResult Result { get; private set; }
-
-      public HashSet<TranslationJob> Jobs { get; private set; }
-
-      public object Component { get; private set; }
-   }
-}

+ 26 - 30
src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs

@@ -4,65 +4,61 @@ using System.Linq;
 using System.Net;
 using System.Text;
 using UnityEngine.UI;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
 using XUnity.AutoTranslator.Plugin.Core.Extensions;
 
 namespace XUnity.AutoTranslator.Plugin.Core
 {
    internal class TranslationJob
    {
-      public TranslationJob( TranslationKey key )
+      public TranslationJob( TranslationEndpointManager endpoint, TranslationKey key, bool saveResult )
       {
+         Endpoint = endpoint;
          Key = key;
+         SaveResultGlobally = saveResult;
 
          Components = new List<object>();
-         OriginalSources = new HashSet<object>();
-         Contexts = new HashSet<TranslationContext>();
+         Contexts = new HashSet<ParserTranslationContext>();
+         TranslationResults = new HashSet<TranslationResult>();
       }
 
-      public HashSet<TranslationContext> Contexts { get; private set; }
+      public bool SaveResultGlobally { get; private set; }
+
+      public TranslationEndpointManager Endpoint { get; private set; }
+
+      public HashSet<ParserTranslationContext> Contexts { get; private set; }
 
       public List<object> Components { get; private set; }
 
-      public HashSet<object> OriginalSources { get; private set; }
+      public HashSet<TranslationResult> TranslationResults { get; private set; }
 
       public TranslationKey Key { get; private set; }
 
       public string TranslatedText { get; set; }
 
+      public string ErrorMessage { get; set; }
+
       public TranslationJobState State { get; set; }
 
-      public bool AnyComponentsStillHasOriginalUntranslatedTextOrContextual()
+      public void Associate( object ui, TranslationResult translationResult, ParserTranslationContext context )
       {
-         if( Components.Count == 0 || Contexts.Count > 0 ) return true; // we do not know
-
-         for( int i = Components.Count - 1 ; i >= 0 ; i-- )
+         if( context != null )
          {
-            var component = Components[ i ];
-            try
+            Contexts.Add( context );
+            context.Jobs.Add( this );
+         }
+         else
+         {
+            if( ui != null && !ui.IsSpammingComponent() )
             {
-               var text = component.GetText().TrimIfConfigured(); 
-               if( text == Key.OriginalText )
-               {
-                  return true;
-               }
+               Components.Add( ui );
             }
-            catch( NullReferenceException )
+
+            if( translationResult != null )
             {
-               // might fail if compoent is no longer associated to game
-               Components.RemoveAt( i );
+               TranslationResults.Add( translationResult );
             }
          }
-
-         return false;
-      }
-
-      public void Associate( TranslationContext context )
-      {
-         if( context != null )
-         {
-            Contexts.Add( context );
-            context.Jobs.Add( this );
-         }
       }
    }
 }

+ 224 - 0
src/XUnity.AutoTranslator.Plugin.Core/TranslationManager.cs

@@ -0,0 +1,224 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+using XUnity.AutoTranslator.Plugin.Core.Web;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   class TranslationManager
+   {
+      public event Action<TranslationJob> JobCompleted;
+      public event Action<TranslationJob> JobFailed;
+
+      private readonly List<TranslationEndpointManager> _endpointsWithUnstartedJobs;
+
+      public TranslationManager()
+      {
+         _endpointsWithUnstartedJobs = new List<TranslationEndpointManager>();
+         ConfiguredEndpoints = new List<TranslationEndpointManager>();
+         AllEndpoints = new List<TranslationEndpointManager>();
+      }
+
+      public int OngoingTranslations { get; set; }
+
+      public int UnstartedTranslations { get; set; }
+
+      public List<TranslationEndpointManager> ConfiguredEndpoints { get; private set; }
+
+      public List<TranslationEndpointManager> AllEndpoints { get; private set; }
+
+      public TranslationEndpointManager CurrentEndpoint { get; set; }
+
+      public void InitializeEndpoints( GameObject go )
+      {
+         try
+         {
+            var httpSecurity = new HttpSecurity();
+            var context = new InitializationContext( httpSecurity, Settings.FromLanguage, Settings.Language );
+
+            CreateEndpoints( go, context );
+
+            AllEndpoints = AllEndpoints
+               .OrderBy( x => x.Error != null )
+               .ThenBy( x => x.Endpoint.FriendlyName )
+               .ToList();
+
+            var primaryEndpoint = AllEndpoints.FirstOrDefault( x => x.Endpoint.Id == Settings.ServiceEndpoint );
+            if( primaryEndpoint != null )
+            {
+               if( primaryEndpoint.Error != null )
+               {
+                  XuaLogger.Current.Error( primaryEndpoint.Error, "Error occurred during the initialization of the selected translate endpoint." );
+               }
+               else
+               {
+                  CurrentEndpoint = primaryEndpoint;
+               }
+            }
+            else if( !string.IsNullOrEmpty( Settings.ServiceEndpoint ) )
+            {
+               XuaLogger.Current.Error( $"Could not find the configured endpoint '{Settings.ServiceEndpoint}'." );
+            }
+
+            if( Settings.DisableCertificateValidation )
+            {
+               XuaLogger.Current.Info( $"Disabling certificate checks for endpoints because of configuration." );
+
+               ServicePointManager.ServerCertificateValidationCallback += ( a1, a2, a3, a4 ) => true;
+            }
+            else
+            {
+               var callback = httpSecurity.GetCertificateValidationCheck();
+               if( callback != null && !Features.SupportsNet4x )
+               {
+                  XuaLogger.Current.Info( $"Disabling certificate checks for endpoints because a .NET 3.x runtime is used." );
+
+                  ServicePointManager.ServerCertificateValidationCallback += callback;
+               }
+               else
+               {
+                  XuaLogger.Current.Info( $"Not disabling certificate checks for endpoints because a .NET 4.x runtime is used." );
+               }
+            }
+
+            // save config because the initialization phase of plugins may have changed the config
+            Settings.Save();
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while constructing endpoints. Shutting plugin down." );
+
+            Settings.IsShutdown = true;
+         }
+      }
+
+      public void CreateEndpoints( GameObject go, InitializationContext context )
+      {
+         var pluginFolder = Path.Combine( PluginEnvironment.Current.PluginPath, Settings.TranslatorsFolder );
+         var dynamicTypes = AssemblyLoader.GetAllTypesOf<ITranslateEndpoint>( pluginFolder );
+
+         foreach( var type in dynamicTypes )
+         {
+            AddEndpoint( go, context, type );
+         }
+      }
+
+      private void AddEndpoint( GameObject go, InitializationContext context, Type type )
+      {
+         ITranslateEndpoint endpoint;
+         try
+         {
+            if( typeof( MonoBehaviour ).IsAssignableFrom( type ) )
+            {
+               // allow implementing plugins to hook into Unity lifecycle
+               endpoint = (ITranslateEndpoint)go.AddComponent( type );
+               UnityEngine.Object.DontDestroyOnLoad( (UnityEngine.Object)endpoint );
+            }
+            else
+            {
+               // or... just use any old object
+               endpoint = (ITranslateEndpoint)Activator.CreateInstance( type );
+            }
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "Could not instantiate class: " + type.Name );
+            return;
+         }
+
+         try
+         {
+            endpoint.Initialize( context );
+            var manager = new TranslationEndpointManager( endpoint, null );
+            RegisterEndpoint( manager );
+         }
+         catch( Exception e )
+         {
+            var manager = new TranslationEndpointManager( endpoint, e );
+            RegisterEndpoint( manager );
+         }
+      }
+
+      public void KickoffTranslations()
+      {
+         // iterate in reverse order so we can remove from list while iterating
+         var endpoints = _endpointsWithUnstartedJobs;
+
+         for( int i = endpoints.Count - 1; i >= 0; i-- )
+         {
+            var endpoint = endpoints[ i ];
+
+            if( Settings.EnableBatching && endpoint.CanBatch )
+            {
+               while( endpoint.HasUnstartedBatch )
+               {
+                  if( endpoint.IsBusy ) break;
+
+                  endpoint.HandleNextBatch();
+               }
+            }
+            else
+            {
+               while( endpoint.HasUnstartedJob )
+               {
+                  if( endpoint.IsBusy ) break;
+
+                  endpoint.HandleNextJob();
+               }
+            }
+         }
+      }
+
+      public void ScheduleUnstartedJobs( TranslationEndpointManager endpoint )
+      {
+         _endpointsWithUnstartedJobs.Add( endpoint );
+      }
+
+      public void UnscheduleUnstartedJobs( TranslationEndpointManager endpoint )
+      {
+         _endpointsWithUnstartedJobs.Remove( endpoint );
+      }
+
+      public void ClearAllJobs()
+      {
+         foreach( var endpoint in ConfiguredEndpoints )
+         {
+            endpoint.ClearAllJobs();
+         }
+      }
+
+      public void RebootAllEndpoints()
+      {
+         foreach( var endpoint in ConfiguredEndpoints )
+         {
+            endpoint.ConsecutiveErrors = 0;
+         }
+      }
+
+      public void RegisterEndpoint( TranslationEndpointManager translationEndpointManager )
+      {
+         translationEndpointManager.Manager = this;
+         AllEndpoints.Add( translationEndpointManager );
+         if( translationEndpointManager.Error == null )
+         {
+            ConfiguredEndpoints.Add( translationEndpointManager );
+         }
+      }
+
+      public void InvokeJobCompleted( TranslationJob job )
+      {
+         JobCompleted?.Invoke( job );
+      }
+
+      public void InvokeJobFailed( TranslationJob job )
+      {
+         JobFailed?.Invoke( job );
+      }
+   }
+}

+ 63 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/AggregatedTranslationViewModel.cs

@@ -0,0 +1,63 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+
+   class AggregatedTranslationViewModel
+   {
+      private List<Translation> _translations;
+      private TranslationAggregatorViewModel _parent;
+      private float? _started;
+
+      public AggregatedTranslationViewModel( TranslationAggregatorViewModel parent, List<Translation> translations )
+      {
+         _parent = parent;
+         _translations = translations;
+         AggregatedTranslations = parent.AvailableTranslators.Select(
+            x => new IndividualTranslatorTranslationViewModel(
+               x,
+               new IndividualTranslationViewModel(
+                  x,
+                  translations.Select( y => new Translation( y.OriginalText, null ) ).ToList() ) ) ).ToList();
+      }
+
+      public List<IndividualTranslatorTranslationViewModel> AggregatedTranslations { get; set; }
+
+      public IEnumerable<string> DefaultTranslations => _translations.Select( x => x.TranslatedText );
+
+      public IEnumerable<string> OriginalTexts => _translations.Select( x => x.OriginalText );
+
+      public void Update()
+      {
+         if( _parent.IsShown )
+         {
+            if( _parent.Manager.OngoingTranslations == 0 && _parent.Manager.UnstartedTranslations == 0 )
+            {
+               if( _started.HasValue )
+               {
+                  var timeSince = Time.realtimeSinceStartup - _started.Value;
+                  if( timeSince > 1.0f )
+                  {
+                     foreach( var additionTranslation in AggregatedTranslations )
+                     {
+                        additionTranslation.Translation.StartTranslations();
+                     }
+                  }
+               }
+               else
+               {
+                  _started = Time.realtimeSinceStartup;
+               }
+            }
+
+            foreach( var additionTranslation in AggregatedTranslations )
+            {
+               additionTranslation.Translation.CheckCompleted();
+            }
+         }
+      }
+   }
+}

+ 25 - 34
src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownGUI.cs

@@ -3,51 +3,38 @@ using UnityEngine;
 
 namespace XUnity.AutoTranslator.Plugin.Core.UI
 {
+
    internal class DropdownGUI<TDropdownOptionViewModel, TSelection>
       where TDropdownOptionViewModel : DropdownOptionViewModel<TSelection>
+      where TSelection : class
    {
 
-      private const int MaxHeight = GUIUtil.RowHeight * 5;
+      private const float MaxHeight = GUIUtil.RowHeight * 5;
 
       private GUIContent _noSelection;
-      private List<TDropdownOptionViewModel> _options;
-      private TDropdownOptionViewModel _currentSelection;
+      private GUIContent _unselect;
+      private DropdownViewModel<TDropdownOptionViewModel, TSelection> _viewModel;
 
-      private int _x;
-      private int _y;
-      private int _width;
+      private float _x;
+      private float _y;
+      private float _width;
       private bool _isShown;
       private Vector2 _scrollPosition;
 
-      public DropdownGUI( int x, int y, int width, IEnumerable<TDropdownOptionViewModel> options )
+      public DropdownGUI( float x, float y, float width, DropdownViewModel<TDropdownOptionViewModel, TSelection> viewModel )
       {
          _x = x;
          _y = y;
          _width = width;
          _noSelection = new GUIContent( "----", "<b>SELECT TRANSLATOR</b>\nNo translator is currently selected, which means no new translations will be performed. Please select one from the dropdown." );
+         _unselect = new GUIContent( "----", "<b>UNSELECT TRANSLATOR</b>\nThis will unselect the current translator, which means no new translations will be performed." );
 
-         _options = new List<TDropdownOptionViewModel>();
-         foreach( var item in options )
-         {
-            if( item.IsSelected() )
-            {
-               _currentSelection = item;
-            }
-            _options.Add( item );
-         }
-      }
-
-      public void Select( TDropdownOptionViewModel option )
-      {
-         if( option.IsSelected() ) return;
-
-         _currentSelection = option;
-         _currentSelection.OnSelected?.Invoke( _currentSelection.Selection );
+         _viewModel = viewModel;
       }
 
       public void OnGUI()
       {
-         bool clicked = GUI.Button( GUIUtil.R( _x, _y, _width, GUIUtil.RowHeight ), _currentSelection?.Text ?? _noSelection, _isShown ? GUIUtil.NoMarginButtonPressedStyle : GUI.skin.button );
+         bool clicked = GUI.Button( GUIUtil.R( _x, _y, _width, GUIUtil.RowHeight ), _viewModel.CurrentSelection?.Text ?? _noSelection, _isShown ? GUIUtil.NoMarginButtonPressedStyle : GUI.skin.button );
          if( clicked )
          {
             _isShown = !_isShown;
@@ -55,7 +42,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 
          if( _isShown )
          {
-            _scrollPosition = ShowDropdown( _x, _y + GUIUtil.RowHeight, _width, GUI.skin.button, _scrollPosition );
+            ShowDropdown( _x, _y + GUIUtil.RowHeight, _width, GUI.skin.button );
          }
 
          if( !clicked && Event.current.isMouse )
@@ -64,21 +51,27 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
          }
       }
 
-      private Vector2 ShowDropdown( int x, int y, int width, GUIStyle buttonStyle, Vector2 scrollPosition )
+      private void ShowDropdown( float x, float y, float width, GUIStyle buttonStyle )
       {
-         var rect = GUIUtil.R( x, y, width, _options.Count * GUIUtil.RowHeight > MaxHeight ? MaxHeight : _options.Count * GUIUtil.RowHeight );
+         var rect = GUIUtil.R( x, y, width, _viewModel.Options.Count * GUIUtil.RowHeight > MaxHeight ? MaxHeight : _viewModel.Options.Count * GUIUtil.RowHeight );
 
          GUILayout.BeginArea( rect, GUIUtil.NoSpacingBoxStyle );
-         scrollPosition = GUILayout.BeginScrollView( scrollPosition );
+         _scrollPosition = GUILayout.BeginScrollView( _scrollPosition );
 
-         foreach( var option in _options )
+         var style = _viewModel.CurrentSelection == null ? GUIUtil.NoMarginButtonPressedStyle : GUIUtil.NoMarginButtonStyle;
+         if( GUILayout.Button( _unselect, style ) )
          {
-            var style = option.IsSelected() ? GUIUtil.NoMarginButtonPressedStyle : GUIUtil.NoMarginButtonStyle;
+            _viewModel.Select( null );
+            _isShown = false;
+         }
 
+         foreach( var option in _viewModel.Options )
+         {
+            style = option.IsSelected() ? GUIUtil.NoMarginButtonPressedStyle : GUIUtil.NoMarginButtonStyle;
             GUI.enabled = option?.IsEnabled() ?? true;
             if( GUILayout.Button( option.Text, style ) )
             {
-               Select( option );
+               _viewModel.Select( option );
                _isShown = false;
             }
             GUI.enabled = true;
@@ -86,8 +79,6 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 
          GUILayout.EndScrollView();
          GUILayout.EndArea();
-
-         return scrollPosition;
       }
    }
 }

+ 38 - 6
src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownOptionViewModel.cs

@@ -1,18 +1,52 @@
 using System;
+using System.Collections.Generic;
 using UnityEngine;
 using XUnity.AutoTranslator.Plugin.Core.Endpoints;
 
 namespace XUnity.AutoTranslator.Plugin.Core.UI
 {
+   internal class DropdownViewModel<TDropdownOptionViewModel, TSelection>
+      where TDropdownOptionViewModel : DropdownOptionViewModel<TSelection>
+      where TSelection : class
+   {
+      private Action<TSelection> _onSelected;
+
+      public DropdownViewModel( IEnumerable<TDropdownOptionViewModel> options, Action<TSelection> onSelected )
+      {
+         _onSelected = onSelected;
+
+         Options = new List<TDropdownOptionViewModel>();
+         foreach( var item in options )
+         {
+            if( item.IsSelected() )
+            {
+               CurrentSelection = item;
+            }
+            Options.Add( item );
+         }
+      }
+
+      public TDropdownOptionViewModel CurrentSelection { get; set; }
+
+      public List<TDropdownOptionViewModel> Options { get; set; }
+
+      public void Select( TDropdownOptionViewModel option )
+      {
+         if( option?.IsSelected() == true ) return;
+
+         CurrentSelection = option;
+         _onSelected?.Invoke( CurrentSelection?.Selection );
+      }
+   }
+
    internal class DropdownOptionViewModel<TSelection>
    {
-      public DropdownOptionViewModel( string text, Func<bool> isSelected, Func<bool> isEnabled, TSelection selection, Action<TSelection> onSelected )
+      public DropdownOptionViewModel( string text, Func<bool> isSelected, Func<bool> isEnabled, TSelection selection )
       {
          Text = new GUIContent( text );
          IsSelected = isSelected;
          IsEnabled = isEnabled;
          Selection = selection;
-         OnSelected = onSelected;
       }
 
       public virtual GUIContent Text { get; set; }
@@ -22,17 +56,15 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
       public Func<bool> IsSelected { get; set; }
 
       public TSelection Selection { get; set; }
-
-      public Action<TSelection> OnSelected { get; set; }
    }
 
-   internal class TranslatorDropdownOptionViewModel : DropdownOptionViewModel<ConfiguredEndpoint>
+   internal class TranslatorDropdownOptionViewModel : DropdownOptionViewModel<TranslationEndpointManager>
    {
       private GUIContent _selected;
       private GUIContent _normal;
       private GUIContent _disabled;
 
-      public TranslatorDropdownOptionViewModel( Func<bool> isSelected, ConfiguredEndpoint selection, Action<ConfiguredEndpoint> onSelected ) : base( selection.Endpoint.FriendlyName, isSelected, () => selection.Error == null, selection, onSelected )
+      public TranslatorDropdownOptionViewModel( Func<bool> isSelected, TranslationEndpointManager selection ) : base( selection.Endpoint.FriendlyName, isSelected, () => selection.Error == null, selection )
       {
          _selected = new GUIContent( selection.Endpoint.FriendlyName, $"<b>CURRENT TRANSLATOR</b>\n{selection.Endpoint.FriendlyName} is the currently selected translator that will be used to perform translations." );
          _disabled = new GUIContent( selection.Endpoint.FriendlyName, $"<b>CANNOT SELECT TRANSLATOR</b>\n{selection.Endpoint.FriendlyName} cannot be selected because the initialization failed. {selection.Error?.Message}" );

+ 36 - 6
src/XUnity.AutoTranslator.Plugin.Core/UI/GUIUtil.cs

@@ -8,14 +8,22 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 {
    internal static class GUIUtil
    {
-      public const int WindowTitleClearance = 10;
-      public const int ComponentSpacing = 10;
-      public const int LabelWidth = 60;
-      public const int LabelHeight = 21;
-      public const int RowHeight = 21;
+      public const float WindowTitleClearance = 10;
+      public const float ComponentSpacing = 10;
+      public const float HalfComponentSpacing = ComponentSpacing / 2;
+      public const float LabelWidth = 60;
+      public const float LabelHeight = 21;
+      public const float RowHeight = 21;
 
       public static readonly RectOffset Empty = new RectOffset( 0, 0, 0, 0 );
 
+      public static readonly GUIStyle LabelTranslation = new GUIStyle( GUI.skin.label )
+      {
+         richText = false,
+         margin = new RectOffset( GUI.skin.label.margin.left, GUI.skin.label.margin.right, 0, 0 ),
+         padding = new RectOffset( GUI.skin.label.padding.left, GUI.skin.label.padding.right, 0, 0 )
+      };
+
       public static readonly GUIStyle LabelCenter = new GUIStyle( GUI.skin.label )
       {
          alignment = TextAnchor.UpperCenter,
@@ -59,7 +67,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
          }
       };
 
-      public static Rect R( int x, int y, int width, int height ) => new Rect( x, y, width, height );
+      public static Rect R( float x, float y, float width, float height ) => new Rect( x, y, width, height );
 
       private static Texture2D CreateBackgroundTexture()
       {
@@ -85,5 +93,27 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 
          return WindowBackgroundStyle;
       }
+
+      public static bool IsAnyMouseButtonOrScrollWheelDown
+      {
+         get
+         {
+            return Input.mouseScrollDelta.y != 0f
+               || Input.GetMouseButtonDown( 0 )
+               || Input.GetMouseButtonDown( 1 )
+               || Input.GetMouseButtonDown( 2 );
+         }
+      }
+
+      public static bool IsAnyMouseButtonOrScrollWheel
+      {
+         get
+         {
+            return Input.mouseScrollDelta.y != 0f
+               || Input.GetMouseButton( 0 )
+               || Input.GetMouseButton( 1 )
+               || Input.GetMouseButton( 2 );
+         }
+      }
    }
 }

+ 70 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslationViewModel.cs

@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class IndividualTranslationViewModel
+   {
+      private string[] _notTranslated = new[] { "Not translated yet." };
+      private string[] _requestingTranslation = new[] { "Requesting translation..." };
+      private List<Translation> _translations;
+      private TranslatorViewModel _translator;
+      private bool _hasStartedTranslation;
+      private bool _isTranslated;
+
+      public IndividualTranslationViewModel( TranslatorViewModel translator, List<Translation> translations )
+      {
+         _translator = translator;
+         _translations = translations;
+      }
+
+      public IEnumerable<string> Translations
+      {
+         get
+         {
+            if( _isTranslated )
+            {
+               return _translations.Select( x => x.TranslatedText );
+            }
+            else if( _hasStartedTranslation )
+            {
+               return _requestingTranslation;
+            }
+            else
+            {
+               return _notTranslated;
+            }
+         }
+      }
+
+      public void StartTranslations()
+      {
+         if( _translator.IsEnabled )
+         {
+            if( !_hasStartedTranslation )
+            {
+               _hasStartedTranslation = true;
+
+               foreach( var translation in _translations )
+               {
+                  translation.PerformTranslation( _translator.Endpoint );
+               }
+            }
+         }
+      }
+
+      public void CheckCompleted()
+      {
+         if( _translator.IsEnabled )
+         {
+            if( !_isTranslated )
+            {
+               if( _translations.All( x => x.TranslatedText != null ) )
+               {
+                  _isTranslated = true;
+               }
+            }
+         }
+      }
+   }
+}

+ 15 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslatorTranslationViewModel.cs

@@ -0,0 +1,15 @@
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class IndividualTranslatorTranslationViewModel
+   {
+      public IndividualTranslatorTranslationViewModel( TranslatorViewModel translator, IndividualTranslationViewModel translation )
+      {
+         Translator = translator;
+         Translation = translation;
+      }
+
+      public TranslatorViewModel Translator { get; private set; }
+
+      public IndividualTranslationViewModel Translation { get; private set; }
+   }
+}

+ 24 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/ScrollViewGUI.cs

@@ -0,0 +1,24 @@
+using System;
+using UnityEngine;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   internal class ScrollPositioned
+   {
+      public ScrollPositioned()
+      {
+      }
+
+      public Vector2 ScrollPosition { get; set; }
+   }
+
+   internal class ScrollPositioned<TViewModel> : ScrollPositioned
+   {
+      public ScrollPositioned( TViewModel viewModel )
+      {
+         ViewModel = viewModel;
+      }
+
+      public TViewModel ViewModel { get; private set; }
+   }
+}

+ 4 - 1
src/XUnity.AutoTranslator.Plugin.Core/UI/ToggleViewModel.cs

@@ -8,12 +8,13 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
       private GUIContent _enabled;
       private GUIContent _disabled;
 
-      public ToggleViewModel( string text, string enabledTooltip, string disabledTooltip, Action onToggled, Func<bool> isToggled )
+      public ToggleViewModel( string text, string enabledTooltip, string disabledTooltip, Action onToggled, Func<bool> isToggled, bool enabled = true )
       {
          _enabled = new GUIContent( text, enabledTooltip );
          _disabled = new GUIContent( text, disabledTooltip );
          OnToggled = onToggled;
          IsToggled = isToggled;
+         Enabled = enabled;
       }
 
       public GUIContent Text
@@ -28,6 +29,8 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
          }
       }
 
+      public bool Enabled { get; set; }
+
       public Action OnToggled { get; set; }
 
       public Func<bool> IsToggled { get; set; }

+ 34 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/Translation.cs

@@ -0,0 +1,34 @@
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class Translation
+   {
+      public Translation( string originalText, string translatedText )
+      {
+         OriginalText = originalText;
+         TranslatedText = translatedText;
+      }
+
+      public string OriginalText { get; set; }
+
+      public string TranslatedText { get; set; }
+
+      public void PerformTranslation( TranslationEndpointManager endpoint )
+      {
+         var response = AutoTranslator.Default.Translate( endpoint, OriginalText );
+         response.Completed += Response_Completed;
+         response.Error += Response_Error;
+      }
+
+      private void Response_Error( string error )
+      {
+         TranslatedText = error;
+      }
+
+      private void Response_Completed( string translatedText )
+      {
+         TranslatedText = translatedText;
+      }
+   }
+}

+ 104 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorOptionsWindow.cs

@@ -0,0 +1,104 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   internal class TranslationAggregatorOptionsWindow
+   {
+      private const int WindowId = 45733721;
+      private const float WindowWidth = 320;
+
+      private Rect _windowRect = new Rect( 20, 20, WindowWidth, 400 );
+      private bool _isMouseDownOnWindow = false;
+      private TranslationAggregatorViewModel _viewModel;
+      private List<ToggleViewModel> _toggles;
+      private Vector2 _scrollPosition;
+
+      public TranslationAggregatorOptionsWindow( TranslationAggregatorViewModel viewModel )
+      {
+         _viewModel = viewModel;
+         _toggles = _viewModel.AllTranslators.Select( x =>
+         new ToggleViewModel(
+            " " + x.Endpoint.Endpoint.FriendlyName,
+            null,
+            null,
+            () => x.IsEnabled = !x.IsEnabled,
+            () => x.IsEnabled,
+            x.Endpoint.Error == null ) ).ToList();
+      }
+
+      public bool IsShown
+      {
+         get => _viewModel.IsShowingOptions;
+         set => _viewModel.IsShowingOptions = value;
+      }
+
+      public void OnGUI()
+      {
+         GUI.Box( _windowRect, GUIContent.none, GUIUtil.GetWindowBackgroundStyle() );
+
+         _windowRect = GUI.Window( WindowId, _windowRect, CreateWindowUI, "---- Translation Aggregator Options ----" );
+
+         if( GUIUtil.IsAnyMouseButtonOrScrollWheelDown )
+         {
+            var point = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+            _isMouseDownOnWindow = _windowRect.Contains( point );
+         }
+
+         if( !_isMouseDownOnWindow || !GUIUtil.IsAnyMouseButtonOrScrollWheel )
+            return;
+
+         // make sure window is focused if scroll wheel is used to indicate we consumed that event
+         GUI.FocusWindow( WindowId );
+
+         var point1 = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+         if( !_windowRect.Contains( point1 ) )
+            return;
+
+         Input.ResetInputAxes();
+      }
+
+      private void CreateWindowUI( int id )
+      {
+         if( GUI.Button( GUIUtil.R( WindowWidth - 22, 2, 20, 16 ), "X" ) )
+         {
+            IsShown = false;
+         }
+
+         GUILayout.Label( "Available Translators" );
+
+         // GROUP
+         _scrollPosition = GUILayout.BeginScrollView( _scrollPosition, GUI.skin.box );
+         
+         foreach( var vm in _toggles )
+         {
+            var previousEnabled = GUI.enabled;
+
+            GUI.enabled = vm.Enabled;
+            var previousValue = vm.IsToggled();
+            var newValue = GUILayout.Toggle( previousValue, vm.Text );
+            if( previousValue != newValue )
+            {
+               vm.OnToggled();
+            }
+
+            GUI.enabled = previousEnabled;
+         }
+
+         GUILayout.EndScrollView();
+
+         GUILayout.BeginHorizontal();
+         GUILayout.Label( "Height" );
+         _viewModel.Height = GUILayout.HorizontalSlider( _viewModel.Height, 50, 300, GUILayout.MaxWidth( 250 ) );
+         GUILayout.EndHorizontal();
+
+         GUILayout.BeginHorizontal();
+         GUILayout.Label( "Width" );
+         _viewModel.Width = GUILayout.HorizontalSlider( _viewModel.Width, 200, 1000, GUILayout.MaxWidth( 250 ) );
+         GUILayout.EndHorizontal();
+
+         GUI.DragWindow();
+      }
+   }
+}

+ 154 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorViewModel.cs

@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class TranslationAggregatorViewModel
+   {
+      private LinkedList<AggregatedTranslationViewModel> _translations;
+      private LinkedListNode<AggregatedTranslationViewModel> _current;
+      private List<Translation> _translationsToAggregate = new List<Translation>();
+      private HashSet<string> _textsToAggregate = new HashSet<string>();
+      private float _lastUpdate = 0.0f;
+
+      public TranslationAggregatorViewModel( TranslationManager translationManager )
+      {
+         _translations = new LinkedList<AggregatedTranslationViewModel>();
+
+         Manager = translationManager;
+         Height = 100; // TODO: Get from config
+         Width = 400; // TODO: Get from config
+
+         AllTranslators = translationManager.AllEndpoints
+            .Select( x => new TranslatorViewModel( x ) )
+            .ToList();
+
+         AvailableTranslators = AllTranslators
+            .Where( x => x.Endpoint.Error == null )
+            .ToList();
+      }
+
+      public bool IsShown { get; set; }
+
+      public bool IsShowingOptions { get; set; }
+
+      public float Height { get; set; }
+
+      public float Width { get; set; }
+
+      public List<TranslatorViewModel> AvailableTranslators { get; }
+
+      public List<TranslatorViewModel> AllTranslators { get; }
+
+      public TranslationManager Manager { get; set; }
+
+      public AggregatedTranslationViewModel Current => _current?.Value;
+
+      public void OnNewTranslationAdded( TextTranslationInfo info )
+      {
+         if( !_textsToAggregate.Contains( info.OriginalText ) )
+         {
+            var vm = new Translation( info.OriginalText, info.TranslatedText );
+
+            _textsToAggregate.Add( info.OriginalText );
+            _translationsToAggregate.Add( vm );
+
+            _lastUpdate = Time.realtimeSinceStartup;
+
+            // never add more than 10 translations to a single window...
+            if( _translationsToAggregate.Count >= 10 )
+            {
+               CreateNewAggregatedTranslation();
+            }
+         }
+      }
+
+      private void CreateNewAggregatedTranslation()
+      {
+         try
+         {
+            var translations = _translationsToAggregate.ToList();
+
+            var vm = new AggregatedTranslationViewModel( this, translations );
+
+            var previousLast = _translations.Last;
+
+            _translations.AddLast( vm );
+            if( _current == null )
+            {
+               _current = _translations.Last;
+            }
+            else
+            {
+               if( _current == previousLast )
+               {
+                  _current = _translations.Last;
+               }
+            }
+
+            // ensure we never have more than 100
+            if( _translations.Count >= 100 )
+            {
+               var first = _translations.First;
+               _translations.RemoveFirst();
+
+               if( _current == first )
+               {
+                  _current = _translations.First;
+               }
+            }
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error while copying text to clipboard." );
+         }
+         finally
+         {
+            _textsToAggregate.Clear();
+            _translationsToAggregate.Clear();
+         }
+      }
+
+      public void Update()
+      {
+         if( _translationsToAggregate.Count > 0 && Time.realtimeSinceStartup - _lastUpdate > Settings.ClipboardDebounceTime )
+         {
+            CreateNewAggregatedTranslation();
+         }
+
+         if( _current != null )
+         {
+            _current.Value.Update();
+         }
+      }
+
+      public bool HasPrevious()
+      {
+         return _current?.Previous != null;
+      }
+
+      public void MovePrevious()
+      {
+         _current = _current.Previous;
+      }
+
+      public bool HasNext()
+      {
+         return _current?.Next != null;
+      }
+
+      public void MoveNext()
+      {
+         _current = _current.Next;
+      }
+
+      public void MoveLatest()
+      {
+         _current = _translations.Last;
+      }
+   }
+}

+ 189 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorWindow.cs

@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   internal class TranslationAggregatorWindow
+   {
+      private static string[] Empty = new string[ 0 ];
+
+      private const int WindowId = 2387602;
+
+      private Rect _windowRect;
+      private bool _isMouseDownOnWindow = false;
+      private TranslationAggregatorViewModel _viewModel;
+
+      private ScrollPositioned _originalText;
+      private ScrollPositioned _defaultTranslation;
+      private ScrollPositioned<TranslatorViewModel>[] _translationViews;
+
+      public TranslationAggregatorWindow( TranslationAggregatorViewModel viewModel )
+      {
+         _viewModel = viewModel;
+
+         _windowRect = new Rect( 20, 20, _viewModel.Width, WindowHeight );
+
+         _originalText = new ScrollPositioned();
+         _defaultTranslation = new ScrollPositioned();
+         _translationViews = viewModel.AvailableTranslators.Select( x => new ScrollPositioned<TranslatorViewModel>( x ) ).ToArray();
+      }
+
+      public bool IsShown
+      {
+         get => _viewModel.IsShown;
+         set => _viewModel.IsShown = value;
+      }
+
+      private float WindowHeight => ( ( _viewModel.AvailableTranslators.Count( x => x.IsEnabled ) + 2 ) * _viewModel.Height ) + 30 + GUIUtil.LabelHeight + GUIUtil.ComponentSpacing;
+
+      public void OnGUI()
+      {
+         _windowRect.height = WindowHeight;
+         _windowRect.width = _viewModel.Width;
+         _windowRect = GUI.Window( WindowId, _windowRect, CreateWindowUI, "---- Translation Aggregator ----" );
+
+         if( GUIUtil.IsAnyMouseButtonOrScrollWheelDown )
+         {
+            var point = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+            _isMouseDownOnWindow = _windowRect.Contains( point );
+         }
+
+         if( !_isMouseDownOnWindow || !GUIUtil.IsAnyMouseButtonOrScrollWheel )
+            return;
+
+         // make sure window is focused if scroll wheel is used to indicate we consumed that event
+         GUI.FocusWindow( WindowId );
+
+         var point1 = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+         if( !_windowRect.Contains( point1 ) )
+            return;
+
+         Input.ResetInputAxes();
+      }
+
+      public void Update()
+      {
+         _viewModel.Update();
+      }
+
+      public void OnNewTranslationAdded( TextTranslationInfo info )
+      {
+         _viewModel.OnNewTranslationAdded( info );
+      }
+
+      private void CreateWindowUI( int id )
+      {
+         float posy = GUIUtil.WindowTitleClearance + GUIUtil.ComponentSpacing;
+
+         if( GUI.Button( GUIUtil.R( _viewModel.Width - 22, 2, 20, 16 ), "X" ) )
+         {
+            IsShown = false;
+         }
+
+         var current = _viewModel.Current;
+         if( current != null )
+         {
+            DrawTextArea( posy, _originalText, "Original Text", current.OriginalTexts );
+            posy += _viewModel.Height;
+
+            DrawTextArea( posy, _defaultTranslation, "Default Translation", current.DefaultTranslations );
+            posy += _viewModel.Height;
+
+            for( int i = 0; i < current.AggregatedTranslations.Count; i++ )
+            {
+               var aggregatedTranslation = current.AggregatedTranslations[ i ];
+               if( aggregatedTranslation.Translator.IsEnabled )
+               {
+                  var scroller = _translationViews[ i ];
+
+                  DrawTextArea(
+                     posy,
+                     scroller,
+                     aggregatedTranslation.Translator.Endpoint.Endpoint.FriendlyName,
+                     aggregatedTranslation.Translation.Translations );
+                  posy += _viewModel.Height;
+               }
+            }
+         }
+         else
+         {
+            DrawTextArea( posy, _originalText, "Original Text", Empty );
+            posy += _viewModel.Height;
+
+            DrawTextArea( posy, _defaultTranslation, "Default Translation", Empty );
+            posy += _viewModel.Height;
+
+            for( int i = 0; i < _viewModel.AvailableTranslators.Count; i++ )
+            {
+               var translator = _viewModel.AvailableTranslators[ i ];
+               if( translator.IsEnabled )
+               {
+                  var scroller = _translationViews[ i ];
+
+                  DrawTextArea(
+                     posy,
+                     scroller,
+                     translator.Endpoint.Endpoint.FriendlyName,
+                     Empty );
+                  posy += _viewModel.Height;
+               }
+            }
+         }
+
+         posy += GUIUtil.HalfComponentSpacing + GUIUtil.ComponentSpacing;
+
+         var previousEnabled = GUI.enabled;
+
+         GUI.enabled = _viewModel.HasPrevious();
+         if( GUI.Button( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, 75, GUIUtil.LabelHeight ), "Previous" ) )
+         {
+            _viewModel.MovePrevious();
+         }
+
+         GUI.enabled = _viewModel.HasNext();
+         if( GUI.Button( GUIUtil.R( GUIUtil.HalfComponentSpacing * 2 + 75 * 1, posy, 75, GUIUtil.LabelHeight ), "Next" ) )
+         {
+            _viewModel.MoveNext();
+         }
+
+         GUI.enabled = _viewModel.HasNext();
+         if( GUI.Button( GUIUtil.R( GUIUtil.HalfComponentSpacing * 3 + 75 * 2, posy, 75, GUIUtil.LabelHeight ), "Last" ) )
+         {
+            _viewModel.MoveLatest();
+         }
+
+         GUI.enabled = true;
+         if( GUI.Button( GUIUtil.R( _viewModel.Width - GUIUtil.HalfComponentSpacing - 75, posy, 75, GUIUtil.LabelHeight ), "Options" ) )
+         {
+            _viewModel.IsShowingOptions = true;
+         }
+
+         GUI.enabled = previousEnabled;
+
+         GUI.DragWindow();
+      }
+
+      private void DrawTextArea( float posy, ScrollPositioned positioned, string title, IEnumerable<string> texts )
+      {
+         GUI.Label( GUIUtil.R( GUIUtil.HalfComponentSpacing + 5, posy + 5, _viewModel.Width - GUIUtil.ComponentSpacing, GUIUtil.LabelHeight ), title );
+
+         posy += GUIUtil.LabelHeight + GUIUtil.HalfComponentSpacing;
+
+         float boxWidth = _viewModel.Width - GUIUtil.ComponentSpacing;
+         float boxHeight = _viewModel.Height - GUIUtil.LabelHeight;
+         GUILayout.BeginArea( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, boxWidth, boxHeight ) );
+         positioned.ScrollPosition = GUILayout.BeginScrollView( positioned.ScrollPosition, GUI.skin.box );
+
+         foreach( var text in texts )
+         {
+            GUILayout.Label( text, GUIUtil.LabelTranslation );
+         }
+
+         GUILayout.EndScrollView();
+         GUILayout.EndArea();
+      }
+   }
+}

+ 17 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/TranslatorViewModel.cs

@@ -0,0 +1,17 @@
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class TranslatorViewModel
+   {
+      public TranslatorViewModel( TranslationEndpointManager endpoint )
+      {
+         Endpoint = endpoint;
+         IsEnabled = false; // TODO: initialize from configuration...
+      }
+
+      public TranslationEndpointManager Endpoint { get; set; }
+
+      public bool IsEnabled { get; set; }
+   }
+}

+ 33 - 0
src/XUnity.AutoTranslator.Plugin.Core/UI/XuaViewModel.cs

@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+
+namespace XUnity.AutoTranslator.Plugin.Core.UI
+{
+   class XuaViewModel
+   {
+      public XuaViewModel(
+         List<ToggleViewModel> toggles,
+         DropdownViewModel<TranslatorDropdownOptionViewModel, TranslationEndpointManager> dropdown,
+         List<ButtonViewModel> commandButtons,
+         List<LabelViewModel> labels )
+      {
+         Toggles = toggles;
+         Dropdown = dropdown;
+         CommandButtons = commandButtons;
+         Labels = labels;
+      }
+
+      public bool IsShown { get; set; }
+
+      public List<ToggleViewModel> Toggles { get; }
+
+      public DropdownViewModel<TranslatorDropdownOptionViewModel, TranslationEndpointManager> Dropdown { get; }
+
+      public List<ButtonViewModel> CommandButtons { get; }
+
+      public List<LabelViewModel> Labels { get; }
+   }
+}

+ 57 - 58
src/XUnity.AutoTranslator.Plugin.Core/UI/XuaWindow.cs

@@ -9,75 +9,72 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 {
    internal class XuaWindow
    {
-      private const int WindowHeight = 480;
-      private const int WindowWidth = 320;
-
-      private const int AvailableWidth = WindowWidth - ( GUIUtil.ComponentSpacing * 2 );
-      private const int AvailableHeight = WindowHeight - GUIUtil.WindowTitleClearance - ( GUIUtil.ComponentSpacing * 2 );
+      private const int WindowId = 5464332;
+      private const float WindowHeight = 520;
+      private const float WindowWidth = 320;
 
       private Rect _windowRect = new Rect( 20, 20, WindowWidth, WindowHeight );
 
-      private DropdownGUI<TranslatorDropdownOptionViewModel, ConfiguredEndpoint> _endpointDropdown;
-
-      private bool _isShown;
-      private List<ToggleViewModel> _toggles;
-      private List<TranslatorDropdownOptionViewModel> _endpointOptions;
-      private List<ButtonViewModel> _commandButtons;
-      private List<LabelViewModel> _labels;
+      private DropdownGUI<TranslatorDropdownOptionViewModel, TranslationEndpointManager> _endpointDropdown;
+      private XuaViewModel _viewModel;
+      private bool _isMouseDownOnWindow = false;
 
       public bool IsShown
       {
-         get
-         {
-            return _isShown;
-         }
-         set
-         {
-            _isShown = value;
-         }
+         get => _viewModel.IsShown;
+         set => _viewModel.IsShown = value;
       }
 
-      public XuaWindow(
-         List<ToggleViewModel> toggles,
-         List<TranslatorDropdownOptionViewModel> endpoints,
-         List<ButtonViewModel> commandButtons,
-         List<LabelViewModel> labels )
+      public XuaWindow( XuaViewModel viewModel )
       {
-         _toggles = toggles;
-         _endpointOptions = endpoints;
-         _commandButtons = commandButtons;
-         _labels = labels;
+         _viewModel = viewModel;
       }
 
       public void OnGUI()
       {
          GUI.Box( _windowRect, GUIContent.none, GUIUtil.GetWindowBackgroundStyle() );
 
-         _windowRect = GUI.Window( 5464332, _windowRect, CreateWindowUI, "---- XUnity.AutoTranslator UI ----" );
+         _windowRect = GUI.Window( WindowId, _windowRect, CreateWindowUI, "---- XUnity.AutoTranslator UI ----" );
+
+         if( GUIUtil.IsAnyMouseButtonOrScrollWheelDown )
+         {
+            var point = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+            _isMouseDownOnWindow = _windowRect.Contains( point );
+         }
+
+         if( !_isMouseDownOnWindow || !GUIUtil.IsAnyMouseButtonOrScrollWheel )
+            return;
+
+         // make sure window is focused if scroll wheel is used to indicate we consumed that event
+         GUI.FocusWindow( WindowId );
+
+         var point1 = new Vector2( Input.mousePosition.x, Screen.height - Input.mousePosition.y );
+         if( !_windowRect.Contains( point1 ) )
+            return;
+
+         Input.ResetInputAxes();
       }
 
       private void CreateWindowUI( int id )
       {
-         int posx = GUIUtil.ComponentSpacing;
-         int posy = GUIUtil.WindowTitleClearance + GUIUtil.ComponentSpacing;
-         const int col2 = WindowWidth - GUIUtil.LabelWidth - ( 3 * GUIUtil.ComponentSpacing );
-         const int col1x = GUIUtil.ComponentSpacing;
-         const int col2x = GUIUtil.LabelWidth + ( GUIUtil.ComponentSpacing * 2 );
-         const int col12 = WindowWidth - ( 2 * GUIUtil.ComponentSpacing );
+         float posx = GUIUtil.ComponentSpacing;
+         float posy = GUIUtil.WindowTitleClearance + GUIUtil.ComponentSpacing;
+         const float col2 = WindowWidth - GUIUtil.LabelWidth - ( 3 * GUIUtil.ComponentSpacing );
+         const float col1x = GUIUtil.ComponentSpacing;
+         const float col2x = GUIUtil.LabelWidth + ( GUIUtil.ComponentSpacing * 2 );
+         const float col12 = WindowWidth - ( 2 * GUIUtil.ComponentSpacing );
 
          if( GUI.Button( GUIUtil.R( WindowWidth - 22, 2, 20, 16 ), "X" ) )
          {
             IsShown = false;
          }
 
-
-         var halfSpacing = GUIUtil.ComponentSpacing / 2;
-
          // GROUP
-         var groupHeight = ( GUIUtil.RowHeight * _toggles.Count ) + ( GUIUtil.ComponentSpacing * ( _toggles.Count ) ) - halfSpacing;
-         GUI.Box( GUIUtil.R( halfSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
+         var toggles = _viewModel.Toggles;
+         var groupHeight = ( GUIUtil.RowHeight * toggles.Count ) + ( GUIUtil.ComponentSpacing * ( toggles.Count ) ) - GUIUtil.HalfComponentSpacing;
+         GUI.Box( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
 
-         foreach( var vm in _toggles )
+         foreach( var vm in toggles )
          {
             var previousValue = vm.IsToggled();
             var newValue = GUI.Toggle( GUIUtil.R( col1x, posy + 3, col12, GUIUtil.RowHeight - 3 ), previousValue, vm.Text );
@@ -88,26 +85,27 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
             posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
          }
 
+         var commandButtons = _viewModel.CommandButtons;
          const int buttonsPerRow = 3;
-         const int buttonWidth = ( col12 - ( GUIUtil.ComponentSpacing * ( buttonsPerRow - 1 ) ) ) / buttonsPerRow;
-         var rows = _commandButtons.Count / buttonsPerRow;
-         if( _commandButtons.Count % 3 != 0 ) rows++;
+         const float buttonWidth = ( col12 - ( GUIUtil.ComponentSpacing * ( buttonsPerRow - 1 ) ) ) / buttonsPerRow;
+         var rows = commandButtons.Count / buttonsPerRow;
+         if( commandButtons.Count % 3 != 0 ) rows++;
 
          // GROUP
-         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * rows ) + ( GUIUtil.ComponentSpacing * ( rows + 1 ) ) - halfSpacing;
-         GUI.Box( GUIUtil.R( halfSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
+         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * rows ) + ( GUIUtil.ComponentSpacing * ( rows + 1 ) ) - GUIUtil.HalfComponentSpacing;
+         GUI.Box( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
 
          GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.LabelHeight ), "---- Command Panel ----", GUIUtil.LabelCenter );
          posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
 
-         for( int row = 0 ; row < rows ; row++ )
+         for( int row = 0; row < rows; row++ )
          {
-            for( int col = 0 ; col < buttonsPerRow ; col++ )
+            for( int col = 0; col < buttonsPerRow; col++ )
             {
                int idx = ( row * buttonsPerRow ) + col;
-               if( idx >= _commandButtons.Count ) break;
+               if( idx >= commandButtons.Count ) break;
 
-               var vm = _commandButtons[ idx ];
+               var vm = commandButtons[ idx ];
 
                GUI.enabled = vm.CanClick?.Invoke() != false;
                if( GUI.Button( GUIUtil.R( posx, posy, buttonWidth, GUIUtil.RowHeight ), vm.Text ) )
@@ -122,31 +120,32 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
          }
 
          // GROUP
-         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * 1 ) + ( GUIUtil.ComponentSpacing * ( 2 ) ) - halfSpacing;
-         GUI.Box( GUIUtil.R( halfSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
+         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * 1 ) + ( GUIUtil.ComponentSpacing * ( 2 ) ) - GUIUtil.HalfComponentSpacing;
+         GUI.Box( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
 
          GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.LabelHeight ), "---- Select a Translator ----", GUIUtil.LabelCenter );
          posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
 
          GUI.Label( GUIUtil.R( col1x, posy, GUIUtil.LabelWidth, GUIUtil.LabelHeight ), "Translator: " );
-         int endpointDropdownPosy = posy;
+         float endpointDropdownPosy = posy;
          posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
 
          // GROUP
-         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * _labels.Count ) + ( GUIUtil.ComponentSpacing * ( _labels.Count + 1 ) ) - halfSpacing;
-         GUI.Box( GUIUtil.R( halfSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
+         var labels = _viewModel.Labels;
+         groupHeight = GUIUtil.LabelHeight + ( GUIUtil.RowHeight * labels.Count ) + ( GUIUtil.ComponentSpacing * ( labels.Count + 1 ) ) - GUIUtil.HalfComponentSpacing;
+         GUI.Box( GUIUtil.R( GUIUtil.HalfComponentSpacing, posy, WindowWidth - GUIUtil.ComponentSpacing, groupHeight ), "" );
 
          GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.LabelHeight ), "---- Status ----", GUIUtil.LabelCenter );
          posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
 
-         foreach( var label in _labels )
+         foreach( var label in labels )
          {
             GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.LabelHeight ), label.Title );
             GUI.Label( GUIUtil.R( col2x, posy, col2, GUIUtil.LabelHeight ), label.GetValue(), GUIUtil.LabelRight );
             posy += GUIUtil.RowHeight + GUIUtil.ComponentSpacing;
          }
 
-         var endpointDropdown = _endpointDropdown ?? ( _endpointDropdown = new DropdownGUI<TranslatorDropdownOptionViewModel, ConfiguredEndpoint>( col2x, endpointDropdownPosy, col2, _endpointOptions ) );
+         var endpointDropdown = _endpointDropdown ?? ( _endpointDropdown = new DropdownGUI<TranslatorDropdownOptionViewModel, TranslationEndpointManager>( col2x, endpointDropdownPosy, col2, _viewModel.Dropdown ) );
          endpointDropdown.OnGUI();
 
          GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.RowHeight * 5 ), GUI.tooltip, GUIUtil.LabelRich );

+ 14 - 0
src/XUnity.AutoTranslator.Plugin.Core/Utilities/CoroutineHelper.cs

@@ -0,0 +1,14 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+
+namespace XUnity.AutoTranslator.Plugin.Core.Utilities
+{
+   internal static class CoroutineHelper
+   {
+      public static Coroutine Start( IEnumerator coroutine ) => AutoTranslationPlugin.Current.StartCoroutine( coroutine );
+   }
+}

+ 20 - 0
src/XUnity.AutoTranslator.Plugin.Core/Utilities/LanguageHelper.cs

@@ -2,11 +2,14 @@
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
+using XUnity.AutoTranslator.Plugin.Core.Extensions;
 
 namespace XUnity.AutoTranslator.Plugin.Core.Utilities
 {
    internal static class LanguageHelper
    {
+      private static Func<string, bool> DefaultSymbolCheck;
       private static readonly Dictionary<string, Func<string, bool>> LanguageSymbolChecks = new Dictionary<string, Func<string, bool>>( StringComparer.OrdinalIgnoreCase )
       {
          { "ja", ContainsJapaneseSymbols },
@@ -41,6 +44,23 @@ namespace XUnity.AutoTranslator.Plugin.Core.Utilities
          return text => true;
       }
 
+      public static bool ContainsLanguageSymbolsForSourceLanguage( string text )
+      {
+         if( DefaultSymbolCheck == null )
+         {
+            DefaultSymbolCheck = GetSymbolCheck( Settings.FromLanguage );
+         }
+
+         return DefaultSymbolCheck( text );
+      }
+
+      public static bool IsTranslatable( string text )
+      {
+         return ContainsLanguageSymbolsForSourceLanguage( text )
+            //&& str.Length <= Settings.MaxCharactersPerTranslation
+            && !Settings.IgnoreTextStartingWith.Any( x => text.StartsWithStrict( x ) );
+      }
+
       public static bool ContainsJapaneseSymbols( string text )
       {
          // Unicode Kanji Table:

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/Utilities/UtageHelper.cs

@@ -62,7 +62,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Utilities
 
          if( !Labels.Contains( label ) )
          {
-            if( AutoTranslationPlugin.Current.TryGetReverseTranslation( label, out string key ) )
+            if( AutoTranslationPlugin.Current.TextCache.TryGetReverseTranslation( label, out string key ) )
             {
                label = key;
             }

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.Core/XUnity.AutoTranslator.Plugin.Core.csproj

@@ -2,7 +2,7 @@
 
    <PropertyGroup>
       <TargetFramework>net35</TargetFramework>
-      <Version>3.1.0</Version>
+      <Version>3.2.0</Version>
       <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
 

+ 8 - 3
src/XUnity.AutoTranslator.Plugin.IPA/AutoTranslatorPlugin.cs

@@ -16,11 +16,12 @@ namespace XUnity.AutoTranslator.Plugin.IPA
    {
       private IniFile _file;
       private string _configPath;
+      private readonly string _dataPath;
 
       public AutoTranslatorPlugin()
       {
-         DataPath = "Plugins";
-         _configPath = Path.Combine( DataPath, "AutoTranslatorConfig.ini" );
+         _dataPath = "Plugins";
+         _configPath = Path.Combine( _dataPath, "AutoTranslatorConfig.ini" );
       }
 
       public IniFile Preferences
@@ -31,7 +32,11 @@ namespace XUnity.AutoTranslator.Plugin.IPA
          }
       }
 
-      public string DataPath { get; }
+      public string PluginPath => _dataPath;
+
+      public string TranslationPath => _dataPath;
+
+      public string ConfigPath => _dataPath;
 
       public IniFile ReloadConfig()
       {

+ 1 - 1
src/XUnity.AutoTranslator.Plugin.IPA/XUnity.AutoTranslator.Plugin.IPA.csproj

@@ -2,7 +2,7 @@
 
    <PropertyGroup>
       <TargetFramework>net35</TargetFramework>
-      <Version>3.1.0</Version>
+      <Version>3.2.0</Version>
    </PropertyGroup>
 
    <ItemGroup>

+ 6 - 0
src/XUnity.AutoTranslator.Plugin.UnityInjector/AutoTranslatorPlugin.cs

@@ -14,6 +14,12 @@ namespace XUnity.AutoTranslator.Plugin.UnityInjector
    [PluginName( PluginData.Name ), PluginVersion( PluginData.Version )]
    public class AutoTranslatorPlugin : PluginBase, IPluginEnvironment
    {
+      public string PluginPath => DataPath;
+
+      public string TranslationPath => DataPath;
+
+      public string ConfigPath => DataPath;
+
       void IPluginEnvironment.SaveConfig()
       {
          SaveConfig();

+ 2 - 2
src/XUnity.AutoTranslator.Plugin.UnityInjector/XUnity.AutoTranslator.Plugin.UnityInjector.csproj

@@ -2,7 +2,7 @@
 
    <PropertyGroup>
       <TargetFramework>net35</TargetFramework>
-      <Version>3.1.0</Version>
+      <Version>3.2.0</Version>
    </PropertyGroup>
 
    <ItemGroup>
@@ -28,7 +28,7 @@
       <ItemGroup>
          <VersionNumber Include="$([System.Text.RegularExpressions.Regex]::Replace(&quot;%(Targets.Version)&quot;, &quot;^(.+?)(\.0+)$&quot;, &quot;$1&quot;))" />
       </ItemGroup>
-      <Exec Command="if $(ConfigurationName) == Release (&#xD;&#xA;   for %%f in (&quot;$(SolutionDir)dist\Translators\*&quot;) do XCOPY /Y /I &quot;%%f&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\Config\Translators\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)0Harmony.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.Core.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.ExtProtocol.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.Core.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)$(TargetName)$(TargetExt)&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   COPY /Y &quot;$(SolutionDir)README.md&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\README (AutoTranslator).md&quot;&#xD;&#xA;   powershell Compress-Archive -Path '$(SolutionDir)dist\UnityInjector\UnityInjector' -DestinationPath '$(SolutionDir)dist\UnityInjector\XUnity.AutoTranslator-UnityInjector-@(VersionNumber).zip' -Force)&#xD;&#xA;)" />
+      <Exec Command="if $(ConfigurationName) == Release (&#xD;&#xA;   for %%f in (&quot;$(SolutionDir)dist\Translators\*&quot;) do XCOPY /Y /I &quot;%%f&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\Config\Translators\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)0Harmony.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.Core.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.AutoTranslator.Plugin.ExtProtocol.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.Core.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)XUnity.RuntimeHooker.dll&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   XCOPY /Y /I &quot;$(TargetDir)$(TargetName)$(TargetExt)&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\&quot;&#xD;&#xA;   COPY /Y &quot;$(SolutionDir)README.md&quot; &quot;$(SolutionDir)dist\UnityInjector\UnityInjector\Config\README (AutoTranslator).md&quot;&#xD;&#xA;   powershell Compress-Archive -Path '$(SolutionDir)dist\UnityInjector\UnityInjector' -DestinationPath '$(SolutionDir)dist\UnityInjector\XUnity.AutoTranslator-UnityInjector-@(VersionNumber).zip' -Force)&#xD;&#xA;)" />
    </Target>
 
 </Project>

+ 1 - 1
src/XUnity.AutoTranslator.Setup/XUnity.AutoTranslator.Setup.csproj

@@ -4,7 +4,7 @@
       <OutputType>Exe</OutputType>
       <TargetFramework>net40</TargetFramework>
       <AssemblyName>SetupReiPatcherAndAutoTranslator</AssemblyName>
-      <Version>3.1.0</Version>
+      <Version>3.2.0</Version>
       <ApplicationIcon>icon.ico</ApplicationIcon>
       <Win32Resource />
    </PropertyGroup>

Some files were not shown because too many files changed in this diff