Jelajahi Sumber

work on splitting out code from main MB

randoman 6 tahun lalu
induk
melakukan
10a474f68d
23 mengubah file dengan 1592 tambahan dan 922 penghapusan
  1. 2 0
      CHANGELOG.md
  2. 9 2
      README.md
  3. 154 614
      src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs
  4. 98 84
      src/XUnity.AutoTranslator.Plugin.Core/Configuration/Settings.cs
  5. 23 12
      src/XUnity.AutoTranslator.Plugin.Core/Debugging/DebugConsole.cs
  6. 0 101
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/ConfiguredEndpoint.cs
  7. 0 66
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/KnownTranslateEndpoints.cs
  8. 483 0
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/TranslationEndpointManager.cs
  9. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Extensions/HarmonyInstanceExtensions.cs
  10. 49 0
      src/XUnity.AutoTranslator.Plugin.Core/ITranslator.cs
  11. 6 2
      src/XUnity.AutoTranslator.Plugin.Core/ParserTranslationContext.cs
  12. 17 0
      src/XUnity.AutoTranslator.Plugin.Core/SpamChecker.cs
  13. 227 0
      src/XUnity.AutoTranslator.Plugin.Core/TextTranslationCache.cs
  14. 158 0
      src/XUnity.AutoTranslator.Plugin.Core/TextureTranslationCache.cs
  15. 18 32
      src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs
  16. 227 0
      src/XUnity.AutoTranslator.Plugin.Core/TranslationManager.cs
  17. 86 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/AggregatedTranslationViewModel.cs
  18. 2 2
      src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownOptionViewModel.cs
  19. 2 2
      src/XUnity.AutoTranslator.Plugin.Core/UI/GUIUtil.cs
  20. 3 3
      src/XUnity.AutoTranslator.Plugin.Core/UI/XuaWindow.cs
  21. 14 0
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/CoroutineHelper.cs
  22. 12 0
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/LanguageHelper.cs
  23. 1 1
      src/XUnity.AutoTranslator.Plugin.Core/Utilities/UtageHelper.cs

+ 2 - 0
CHANGELOG.md

@@ -1,5 +1,7 @@
 ### 3.1.1
  * BUG FIX - Interacting with UI now blocks input to game
+ * BUG FIX - Better handling of error'ed translations in relation to rich text
+ * MISC - Removed 'Dump Untranslated Texts' hotkey due to feature bloat
 
 ### 3.1.0
  * FEATURE - Support for games with 'netstandard2.0' API surface through config option 'EnableExperimentalHooks'

+ 9 - 2
README.md

@@ -41,6 +41,8 @@ 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!)
@@ -57,6 +59,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 +78,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 +109,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 +121,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 +166,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)

File diff ditekan karena terlalu besar
+ 154 - 614
src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs


+ 98 - 84
src/XUnity.AutoTranslator.Plugin.Core/Configuration/Settings.cs

@@ -39,7 +39,6 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
       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 +99,119 @@ 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", 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;
+            }
+
+            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.DataPath, 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." );
+
+            IsShutdown = true;
          }
+      }
 
+      public static void SetEndpoint( string id )
+      {
+         ServiceEndpoint = id;
+         PluginEnvironment.Current.Preferences[ "Service" ][ "Endpoint" ].Value = id;
+         Save();
+      }
 
-         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 )
+      internal static void Save()
+      {
+         try
          {
-            PluginEnvironment.Current.Preferences[ "Behaviour" ][ "MaxCharactersPerTranslation" ].Value = MaxMaxCharactersPerTranslation.ToString( CultureInfo.InvariantCulture );
-            MaxCharactersPerTranslation = MaxMaxCharactersPerTranslation;
+            PluginEnvironment.Current.SaveConfig();
          }
-
-         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.DataPath, OutputFile.Replace( "{lang}", Language ) ).Replace( "/", "\\" ).Parameterize();
-         UsesWhitespaceBetweenWords = LanguageHelper.RequiresWhitespaceUponLineMerging( FromLanguage );
-
-         if( EnableMigrations )
+         catch( Exception e )
          {
-            Migrate();
+            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()

+ 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." );
+            }
+         }
       }
    }
 }

+ 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();
-         }
-      }
-   }
-}

+ 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 ) );
-         }
-      }
-   }
-}

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

@@ -0,0 +1,483 @@
+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;
+
+      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>();
+
+         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 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;
+
+                  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;
+
+               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;
+
+               _ongoingJobs.Remove( job.Key.GetDictionaryLookupKey() );
+               Manager.OngoingTranslations--;
+
+               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 );
+               _ongoingJobs.Remove( key );
+               Manager.OngoingTranslations--;
+            }
+
+            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;
+
+         _ongoingJobs.Remove( job.Key.GetDictionaryLookupKey() );
+         Manager.OngoingTranslations--;
+
+         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;
+               _ongoingJobs.Remove( untranslatedText );
+               Manager.OngoingTranslations--;
+
+               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 );
+               _ongoingJobs.Remove( key );
+               Manager.OngoingTranslations--;
+            }
+
+            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 plugin." );
+
+               ClearAllJobs();
+            }
+         }
+      }
+
+      private IEnumerator EnableBatchingAfterDelay()
+      {
+         yield return new WaitForSeconds( 240 );
+
+         HasBatchLogicFailed = false;
+
+         XuaLogger.Current.Info( "Re-enabled batching." );
+      }
+
+      public bool EnqueueTranslation( object ui, TranslationKey key, ParserTranslationContext context, bool checkOtherEndpoints )
+      {
+         var lookupKey = key.GetDictionaryLookupKey();
+
+         var added = AssociateWithExistingJobIfPossible( ui, lookupKey, context );
+         if( added )
+         {
+            return false;
+         }
+
+         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, context );
+               if( added )
+               {
+                  return false;
+               }
+            }
+         }
+
+         XuaLogger.Current.Debug( "Queued: '" + lookupKey + "'" );
+
+         var newJob = new TranslationJob( this, key, true );
+         newJob.Associate( ui, context );
+
+         return AddUnstartedJob( lookupKey, newJob );
+      }
+
+      public bool AssociateWithExistingJobIfPossible( object ui, string key, ParserTranslationContext context )
+      {
+         if( _unstartedJobs.TryGetValue( key, out TranslationJob unstartedJob ) )
+         {
+            unstartedJob.Associate( ui, context );
+            return true;
+         }
+
+         if( _ongoingJobs.TryGetValue( key, out TranslationJob ongoingJob ) )
+         {
+            ongoingJob.Associate( ui, 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;
+      }
+
+      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;
+
+            Manager.InvokeJobFailed( job.Value );
+         }
+
+         Manager.OngoingTranslations -= ongoingCount;
+         Manager.UnstartedTranslations -= unstartedCount;
+
+         Manager.UnscheduleUnstartedJobs( this );
+      }
+
+      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/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}'." );
             }
          }
       }

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

@@ -0,0 +1,49 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   interface ITranslator
+   {
+      TranslationResult Translate( string untranslatedText ); // or just use callback?
+   }
+
+   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 void SetCompleted( string translatedText )
+      {
+         TranslatedText = translatedText;
+         IsCompleted = true;
+
+         Completed?.Invoke( translatedText );
+      }
+
+      public void SetError()
+      {
+         IsCompleted = true;
+
+         Error?.Invoke( "Oh no!" );
+      }
+
+      public object Current => null;
+
+      public bool MoveNext()
+      {
+         return !IsCompleted;
+      }
+
+      public void Reset()
+      {
+      }
+   }
+}

+ 6 - 2
src/XUnity.AutoTranslator.Plugin.Core/TranslationContext.cs → src/XUnity.AutoTranslator.Plugin.Core/ParserTranslationContext.cs

@@ -1,11 +1,13 @@
 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 TranslationContext
+   internal class ParserTranslationContext
    {
-      public TranslationContext( object component, ParserResult result )
+      public ParserTranslationContext( object component, ParserResult result )
       {
          Jobs = new HashSet<TranslationJob>();
          Component = component;
@@ -17,5 +19,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
       public HashSet<TranslationJob> Jobs { get; private set; }
 
       public object Component { get; private set; }
+
+      public TranslationEndpointManager Endpoint => Jobs.FirstOrDefault()?.Endpoint;
    }
 }

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

@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace XUnity.AutoTranslator.Plugin.Core
+{
+   class SpamChecker
+   {
+      private TranslationManager _translationManager;
+
+      public SpamChecker( TranslationManager translationManager )
+      {
+         _translationManager = translationManager;
+      }
+   }
+}

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

@@ -0,0 +1,227 @@
+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.DataPath, 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.DataPath, 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();
+               }
+            }
+         }
+      }
+
+      internal bool HasTranslated( string key )
+      {
+         return _translations.ContainsKey( key );
+      }
+
+      internal bool IsTranslation( string translation )
+      {
+         return _reverseTranslations.ContainsKey( translation );
+      }
+
+      internal void AddTranslation( string key, string value )
+      {
+         _translations[ key ] = value;
+         _reverseTranslations[ value ] = key;
+      }
+
+      internal void AddTranslation( TranslationKey key, string value )
+      {
+         var lookup = key.GetDictionaryLookupKey();
+         _translations[ lookup ] = value;
+         _reverseTranslations[ value ] = lookup;
+      }
+
+      internal void QueueNewTranslationForDisk( TranslationKey key, string value )
+      {
+         lock( _writeToFileSync )
+         {
+            _newTranslations[ key.GetDictionaryLookupKey() ] = value;
+         }
+      }
+
+      internal void QueueNewTranslationForDisk( string key, string value )
+      {
+         lock( _writeToFileSync )
+         {
+            _newTranslations[ 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 str )
+      {
+         return LanguageHelper.ContainsLanguageSymbolsForSourceLanguage( str )
+            //&& str.Length <= Settings.MaxCharactersPerTranslation
+            && !IsTranslation( str )
+            && !Settings.IgnoreTextStartingWith.Any( x => str.StartsWithStrict( x ) );
+      }
+   }
+}

+ 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.DataPath, 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.DataPath, 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.DataPath, 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 );
+      }
+   }
+}

+ 18 - 32
src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs

@@ -4,26 +4,30 @@ 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;
+         SaveResult = saveResult;
 
          Components = new List<object>();
-         OriginalSources = new HashSet<object>();
-         Contexts = new HashSet<TranslationContext>();
+         Contexts = new HashSet<ParserTranslationContext>();
       }
 
-      public HashSet<TranslationContext> Contexts { get; private set; }
+      public bool SaveResult { get; private set; }
 
-      public List<object> Components { get; private set; }
+      public TranslationEndpointManager Endpoint { get; private set; }
+
+      public HashSet<ParserTranslationContext> Contexts { get; private set; }
 
-      public HashSet<object> OriginalSources { get; private set; }
+      public List<object> Components { get; private set; }
 
       public TranslationKey Key { get; private set; }
 
@@ -31,38 +35,20 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
       public TranslationJobState State { get; set; }
 
-      public bool AnyComponentsStillHasOriginalUntranslatedTextOrContextual()
-      {
-         if( Components.Count == 0 || Contexts.Count > 0 ) return true; // we do not know
-
-         for( int i = Components.Count - 1 ; i >= 0 ; i-- )
-         {
-            var component = Components[ i ];
-            try
-            {
-               var text = component.GetText().TrimIfConfigured(); 
-               if( text == Key.OriginalText )
-               {
-                  return true;
-               }
-            }
-            catch( NullReferenceException )
-            {
-               // might fail if compoent is no longer associated to game
-               Components.RemoveAt( i );
-            }
-         }
-
-         return false;
-      }
-
-      public void Associate( TranslationContext context )
+      public void Associate( object ui, ParserTranslationContext context )
       {
          if( context != null )
          {
             Contexts.Add( context );
             context.Jobs.Add( this );
          }
+         else
+         {
+            if( ui != null && !ui.IsSpammingComponent() )
+            {
+               Components.Add( ui );
+            }
+         }
       }
    }
 }

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

@@ -0,0 +1,227 @@
+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.DataPath, Settings.PluginFolder );
+         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();
+         }
+
+         UnstartedTranslations = 0;
+         OngoingTranslations = 0;
+      }
+
+      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 );
+      }
+   }
+}

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

@@ -0,0 +1,86 @@
+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 AggregatedTranslationViewModel
+   {
+      private string _originalText;
+      private string _mainTranslation;
+      private Dictionary<TranslatorViewModel, string> _additionalTranslations;
+
+      public AggregatedTranslationViewModel( TranslationAggregatorViewModel parent, TextTranslationInfo info )
+      {
+         _originalText = info.OriginalText;
+         _mainTranslation = info.TranslatedText;
+         _additionalTranslations = parent.Endpoints.ToDictionary( x => x, x => (string)null );
+      }
+
+      public void StartAdditionnalTranslations()
+      {
+         foreach( var additionalTranslation in _additionalTranslations.Where( x => x.Key.IsEnabled ) )
+         {
+            var vm = additionalTranslation.Key;
+            var translation = additionalTranslation.Value;
+            if( translation == null )
+            {
+
+            }
+         }
+      }
+   }
+
+   class TranslationAggregatorViewModel
+   {
+      private LinkedList<AggregatedTranslationViewModel> _translations;
+      private LinkedListNode<AggregatedTranslationViewModel> _current;
+      private List<TranslatorViewModel> _endpoints;
+
+      public TranslationAggregatorViewModel( IEnumerable<TranslationEndpointManager> endpoints )
+      {
+         _translations = new LinkedList<AggregatedTranslationViewModel>();
+         _endpoints = endpoints
+            .Where( x => x.Error == null )
+            .Select( x => new TranslatorViewModel( x ) )
+            .ToList();
+      }
+
+      public List<TranslatorViewModel> Endpoints => _endpoints;
+
+      public void OnNewTranslationAdded( TextTranslationInfo info )
+      {
+         var vm = new AggregatedTranslationViewModel( this, info );
+
+         var previousLast = _translations.Last;
+
+         _translations.AddLast( vm );
+         if( _current == null )
+         {
+            _current = _translations.Last;
+         }
+         else
+         {
+            if( _current == previousLast )
+            {
+               _current = _translations.Last;
+            }
+         }
+      }
+   }
+
+   class TranslatorViewModel
+   {
+      private TranslationEndpointManager _endpoint;
+
+      public TranslatorViewModel( TranslationEndpointManager endpoint )
+      {
+         _endpoint = endpoint;
+         IsEnabled = false; // initialize from configuration...
+      }
+
+      public bool IsEnabled { get; set; }
+   }
+}

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

@@ -26,13 +26,13 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
       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, Action<TranslationEndpointManager> onSelected ) : base( selection.Endpoint.FriendlyName, isSelected, () => selection.Error == null, selection, onSelected )
       {
          _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}" );

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

@@ -90,7 +90,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
       {
          get
          {
-            return Input.mouseScrollDelta.y > 0f
+            return Input.mouseScrollDelta.y != 0f
                || Input.GetMouseButtonDown( 0 )
                || Input.GetMouseButtonDown( 1 )
                || Input.GetMouseButtonDown( 2 );
@@ -101,7 +101,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
       {
          get
          {
-            return Input.mouseScrollDelta.y > 0f
+            return Input.mouseScrollDelta.y != 0f
                || Input.GetMouseButton( 0 )
                || Input.GetMouseButton( 1 )
                || Input.GetMouseButton( 2 );

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

@@ -10,7 +10,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
    internal class XuaWindow
    {
       private const int WindowId = 5464332;
-      private const int WindowHeight = 480;
+      private const int WindowHeight = 520;
       private const int WindowWidth = 320;
 
       private const int AvailableWidth = WindowWidth - ( GUIUtil.ComponentSpacing * 2 );
@@ -18,7 +18,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 
       private Rect _windowRect = new Rect( 20, 20, WindowWidth, WindowHeight );
 
-      private DropdownGUI<TranslatorDropdownOptionViewModel, ConfiguredEndpoint> _endpointDropdown;
+      private DropdownGUI<TranslatorDropdownOptionViewModel, TranslationEndpointManager> _endpointDropdown;
       private List<ToggleViewModel> _toggles;
       private List<TranslatorDropdownOptionViewModel> _endpointOptions;
       private List<ButtonViewModel> _commandButtons;
@@ -154,7 +154,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
             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, _endpointOptions ) );
          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 );
+   }
+}

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

@@ -2,11 +2,13 @@
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
+using XUnity.AutoTranslator.Plugin.Core.Configuration;
 
 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 +43,16 @@ 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 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;
             }

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini