Просмотр исходного кода

work on seperate translation feature

randoman 6 лет назад
Родитель
Сommit
0d7f36362b
24 измененных файлов с 1413 добавлено и 429 удалено
  1. 3 1
      CHANGELOG.md
  2. 292 268
      src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs
  3. 2 2
      src/XUnity.AutoTranslator.Plugin.Core/Configuration/Settings.cs
  4. 2 0
      src/XUnity.AutoTranslator.Plugin.Core/Constants/ClrTypes.cs
  5. 66 9
      src/XUnity.AutoTranslator.Plugin.Core/Endpoints/TranslationEndpointManager.cs
  6. 22 0
      src/XUnity.AutoTranslator.Plugin.Core/Hooks/ImageHooks.cs
  7. 88 8
      src/XUnity.AutoTranslator.Plugin.Core/ITranslator.cs
  8. 6 2
      src/XUnity.AutoTranslator.Plugin.Core/ParserTranslationContext.cs
  9. 211 0
      src/XUnity.AutoTranslator.Plugin.Core/SpamChecker.cs
  10. 14 15
      src/XUnity.AutoTranslator.Plugin.Core/TextTranslationCache.cs
  11. 13 3
      src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs
  12. 27 64
      src/XUnity.AutoTranslator.Plugin.Core/UI/AggregatedTranslationViewModel.cs
  13. 7 6
      src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownGUI.cs
  14. 12 6
      src/XUnity.AutoTranslator.Plugin.Core/UI/GUIUtil.cs
  15. 64 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslationViewModel.cs
  16. 15 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/IndividualTranslatorTranslationViewModel.cs
  17. 26 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/ScrollViewGUI.cs
  18. 34 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/Translation.cs
  19. 93 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorOptionsWindow.cs
  20. 142 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorViewModel.cs
  21. 189 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslationAggregatorWindow.cs
  22. 17 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/TranslatorViewModel.cs
  23. 32 0
      src/XUnity.AutoTranslator.Plugin.Core/UI/XuaViewModel.cs
  24. 36 45
      src/XUnity.AutoTranslator.Plugin.Core/UI/XuaWindow.cs

+ 3 - 1
CHANGELOG.md

@@ -1,7 +1,9 @@
-### 3.1.1
+### 3.2.0
+ * 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
  * MISC - Removed 'Dump Untranslated Texts' hotkey due to feature bloat
+ * MISC - Improved Utage image hooking to support DicingImage
 
 ### 3.1.0
  * FEATURE - Support for games with 'netstandard2.0' API surface through config option 'EnableExperimentalHooks'

+ 292 - 268
src/XUnity.AutoTranslator.Plugin.Core/AutoTranslationPlugin.cs

@@ -36,7 +36,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
    /// <summary>
    /// Main plugin class for the AutoTranslator.
    /// </summary>
-   public class AutoTranslationPlugin : MonoBehaviour
+   public class AutoTranslationPlugin : MonoBehaviour, ITranslator
    {
       /// <summary>
       /// Allow the instance to be accessed statically, as only one will exist.
@@ -44,9 +44,12 @@ namespace XUnity.AutoTranslator.Plugin.Core
       internal static AutoTranslationPlugin Current;
 
       internal XuaWindow MainWindow;
+      internal TranslationAggregatorWindow TranslationAggregatorWindow;
+      internal TranslationAggregatorOptionsWindow TranslationAggregatorOptionsWindow;
       internal TranslationManager TranslationManager;
       internal TextTranslationCache TextCache;
       internal TextureTranslationCache TextureCache;
+      internal SpamChecker SpamChecker;
 
       /// <summary>
       /// Keeps track of things to copy to clipboard.
@@ -66,27 +69,12 @@ namespace XUnity.AutoTranslator.Plugin.Core
       /// </summary>
       private HashSet<string> _immediatelyTranslating = new HashSet<string>();
 
-      private int[] _currentTranslationsQueuedPerSecondRollingWindow = new int[ Settings.TranslationQueueWatchWindow ];
-      private float? _timeExceededThreshold;
-      private float _translationsQueuedPerSecond;
-
       private bool _isInTranslatedMode = true;
       private bool _textHooksEnabled = true;
       private bool _imageHooksEnabled = true;
 
       private float _batchOperationSecondCounter = 0;
 
-      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 bool _hasValidOverrideFont = false;
       private bool _hasOverridenFont = false;
       private bool _initialized = false;
@@ -126,6 +114,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
          TranslationManager.JobCompleted += OnJobCompleted;
          TranslationManager.JobFailed += OnJobFailed;
          TranslationManager.InitializeEndpoints( gameObject );
+         SpamChecker = new SpamChecker( TranslationManager );
 
          // WORKAROUND: Initialize text parsers with delegate indicating if text should be translated
          UnityTextParsers.Initialize( text => TextCache.IsTranslatable( text ) && IsBelowMaxLength( text ) );
@@ -154,32 +143,11 @@ namespace XUnity.AutoTranslator.Plugin.Core
          {
             DisableAutoTranslator();
 
-            MainWindow = new XuaWindow(
-               new List<ToggleViewModel>
-               {
-                  new ToggleViewModel(
-                     " Translated",
-                     "<b>TRANSLATED</b>\nThe plugin currently displays translated texts. Disabling this does not mean the plugin will no longer perform translations, just that they will not be displayed.",
-                     "<b>NOT TRANSLATED</b>\nThe plugin currently displays untranslated texts.",
-                     ToggleTranslation, () => _isInTranslatedMode )
-               },
-               TranslationManager.AllEndpoints.Select( x => new TranslatorDropdownOptionViewModel( () => x == TranslationManager.CurrentEndpoint, x, OnEndpointSelected ) ).ToList(),
-               new List<ButtonViewModel>
-               {
-                  new ButtonViewModel( "Reboot", "<b>REBOOT PLUGIN</b>\nReboots the plugin if it has been shutdown. This only works if the plugin was shut down due to consequtive errors towards the translation endpoint.", RebootPlugin, null ),
-                  new ButtonViewModel( "Reload", "<b>RELOAD TRANSLATION</b>\nReloads all translation text files and texture files from disk.", ReloadTranslations, null ),
-                  new ButtonViewModel( "Hook", "<b>MANUAL HOOK</b>\nTraverses the unity object tree for looking for anything that can be translated. Performs a translation if something is found.", ManualHook, null )
-               },
-               new List<LabelViewModel>
-               {
-                  new LabelViewModel( "Version: ", () => PluginData.Version ),
-                  new LabelViewModel( "Plugin status: ", () => Settings.IsShutdown ? "Shutdown" : "Running" ),
-                  new LabelViewModel( "Translator status: ", () => TranslationManager.CurrentEndpoint.HasFailedDueToConsecutiveErrors ? "Shutdown" : "Running" ),
-                  new LabelViewModel( "Running translations: ", () => $"{(TranslationManager.OngoingTranslations)}" ),
-                  new LabelViewModel( "Served translations: ", () => $"{Settings.TranslationCount} / {Settings.MaxTranslationsBeforeShutdown}" ),
-                  new LabelViewModel( "Queued translations: ", () => $"{(TranslationManager.UnstartedTranslations)} / {Settings.MaxUnstartedJobs}" ),
-                  new LabelViewModel( "Error'ed translations: ", () => $"{TranslationManager.CurrentEndpoint?.ConsecutiveErrors ?? 0} / {Settings.MaxErrors}"  ),
-               } );
+            MainWindow = new XuaWindow( CreateXuaViewModel() );
+
+            var vm = CreateTranslationAggregatorViewModel();
+            TranslationAggregatorWindow = new TranslationAggregatorWindow( vm );
+            TranslationAggregatorOptionsWindow = new TranslationAggregatorOptionsWindow( vm );
          }
          catch( Exception e )
          {
@@ -191,6 +159,41 @@ namespace XUnity.AutoTranslator.Plugin.Core
          }
       }
 
+      private TranslationAggregatorViewModel CreateTranslationAggregatorViewModel()
+      {
+         return new TranslationAggregatorViewModel( TranslationManager.ConfiguredEndpoints );
+      }
+
+      private XuaViewModel CreateXuaViewModel()
+      {
+         return new XuaViewModel(
+            new List<ToggleViewModel>
+            {
+               new ToggleViewModel(
+                  " Translated",
+                  "<b>TRANSLATED</b>\nThe plugin currently displays translated texts. Disabling this does not mean the plugin will no longer perform translations, just that they will not be displayed.",
+                  "<b>NOT TRANSLATED</b>\nThe plugin currently displays untranslated texts.",
+                  ToggleTranslation, () => _isInTranslatedMode )
+            },
+            TranslationManager.AllEndpoints.Select( x => new TranslatorDropdownOptionViewModel( () => x == TranslationManager.CurrentEndpoint, x, OnEndpointSelected ) ).ToList(),
+            new List<ButtonViewModel>
+            {
+               new ButtonViewModel( "Reboot", "<b>REBOOT PLUGIN</b>\nReboots the plugin if it has been shutdown. This only works if the plugin was shut down due to consequtive errors towards the translation endpoint.", RebootPlugin, null ),
+               new ButtonViewModel( "Reload", "<b>RELOAD TRANSLATION</b>\nReloads all translation text files and texture files from disk.", ReloadTranslations, null ),
+               new ButtonViewModel( "Hook", "<b>MANUAL HOOK</b>\nTraverses the unity object tree for looking for anything that can be translated. Performs a translation if something is found.", ManualHook, null )
+            },
+            new List<LabelViewModel>
+            {
+               new LabelViewModel( "Version: ", () => PluginData.Version ),
+               new LabelViewModel( "Plugin status: ", () => Settings.IsShutdown ? "Shutdown" : "Running" ),
+               new LabelViewModel( "Translator status: ", () => TranslationManager.CurrentEndpoint.HasFailedDueToConsecutiveErrors ? "Shutdown" : "Running" ),
+               new LabelViewModel( "Running translations: ", () => $"{(TranslationManager.OngoingTranslations)}" ),
+               new LabelViewModel( "Served translations: ", () => $"{Settings.TranslationCount} / {Settings.MaxTranslationsBeforeShutdown}" ),
+               new LabelViewModel( "Queued translations: ", () => $"{(TranslationManager.UnstartedTranslations)} / {Settings.MaxUnstartedJobs}" ),
+               new LabelViewModel( "Error'ed translations: ", () => $"{TranslationManager.CurrentEndpoint?.ConsecutiveErrors ?? 0} / {Settings.MaxErrors}"  ),
+            } );
+      }
+
       private void StartMaintenance()
       {
          // start a thread that will periodically removed unused references
@@ -345,176 +348,13 @@ namespace XUnity.AutoTranslator.Plugin.Core
          TextureCache.LoadTranslationFiles();
       }
 
-      private void CreateTranslationJobFor( TranslationEndpointManager endpoint, object ui, TranslationKey key, ParserTranslationContext context )
-      {
-         var added = endpoint.EnqueueTranslation( ui, key, context, true );
-         if( added )
-         {
-            CheckStaggerText( key.GetDictionaryLookupKey() );
-            CheckConsecutiveFrames();
-            CheckConsecutiveSeconds();
-            CheckThresholds();
-         }
-      }
-
-      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()
+      private void CreateTranslationJobFor( TranslationEndpointManager endpoint, object ui, TranslationKey key, TranslationResult translationResult, ParserTranslationContext context )
       {
-         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 )
+         var added = endpoint.EnqueueTranslation( ui, key, translationResult, context );
+         if( added && translationResult == null )
          {
-            // 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;
+            // FIXME: Check that we still enter this!
+            SpamChecker.PerformChecks( key.GetDictionaryLookupKey() );
          }
       }
 
@@ -537,24 +377,6 @@ namespace XUnity.AutoTranslator.Plugin.Core
          }
       }
 
-      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;
-         }
-      }
-
       private void UpdateSpriteRenderers()
       {
          if( Settings.EnableSpriteRendererHooking && ( Settings.EnableTextureTranslation || Settings.EnableTextureDumping ) )
@@ -743,6 +565,11 @@ namespace XUnity.AutoTranslator.Plugin.Core
                ui.SetText( text );
 
                info?.ResetScrollIn( ui );
+
+               if( TranslationAggregatorWindow != null && info != null && !ui.IsSpammingComponent() )
+               {
+                  TranslationAggregatorWindow.OnNewTranslationAdded( info );
+               }
             }
             catch( TargetInvocationException )
             {
@@ -1126,6 +953,125 @@ namespace XUnity.AutoTranslator.Plugin.Core
          return null;
       }
 
+      // "public" endpoint to perform translations
+      TranslationResult ITranslator.Translate( TranslationEndpointManager endpoint, string untranslatedText )
+      {
+         return Translate( untranslatedText, endpoint, null );
+      }
+
+      private TranslationResult Translate( string text, TranslationEndpointManager endpoint, ParserTranslationContext context )
+      {
+         var result = new TranslationResult();
+
+         if( context == null )
+         {
+            // Get the trimmed text
+            text = text.TrimIfConfigured();
+         }
+
+         // Ensure that we actually want to translate this text and its owning UI element.
+         // FIXME: Using TextCache here is wrong!
+         if( !string.IsNullOrEmpty( text ) && TextCache.IsTranslatable( text ) && IsBelowMaxLength( text ) )
+         {
+            var textKey = new TranslationKey( null, text, false, context != null );
+
+            // if we already have translation loaded in our _translatios dictionary, simply load it and set text
+            string translation;
+            if( endpoint.TryGetTranslation( textKey, out translation ) )
+            {
+               if( !string.IsNullOrEmpty( translation ) )
+               {
+                  result.SetCompleted( translation, true );
+               }
+               else
+               {
+                  result.SetEmptyResponse( true );
+               }
+               return result;
+            }
+            else
+            {
+               if( context == null )
+               {
+                  var parserResult = UnityTextParsers.RichTextParser.Parse( text );
+                  if( parserResult.Succeeded )
+                  {
+                     translation = TranslateByParserResult( endpoint, parserResult, result, true );
+                     if( translation != null )
+                     {
+                        result.SetCompleted( translation, true );
+                     }
+
+                     // return because result will be completed later by recursive call
+
+                     return result;
+                  }
+               }
+
+               if( Settings.IsShutdown )
+               {
+                  result.SetErrorWithMessage( "The plugin is shutdown.", true );
+               }
+               else if( endpoint.HasFailedDueToConsecutiveErrors )
+               {
+                  result.SetErrorWithMessage( "The translation endpoint is shutdown.", true );
+               }
+               else
+               {
+                  CreateTranslationJobFor( endpoint, null, textKey, result, context );
+               }
+
+               return result;
+            }
+         }
+         else
+         {
+            result.SetErrorWithMessage( $"The provided text ({text}) cannot be translated.", true );
+
+            return result;
+         }
+      }
+
+      private string TranslateByParserResult( TranslationEndpointManager endpoint, ParserResult result, TranslationResult translationResult, bool allowStartJob )
+      {
+         Dictionary<string, string> translations = new Dictionary<string, string>();
+
+         // attempt to lookup ALL strings immediately; return result if possible; queue operations
+         foreach( var kvp in result.Arguments )
+         {
+            var variableName = kvp.Key;
+            var untranslatedTextPart = kvp.Value;
+            if( !string.IsNullOrEmpty( untranslatedTextPart ) && TextCache.IsTranslatable( untranslatedTextPart ) && IsBelowMaxLength( untranslatedTextPart ) )
+            {
+               string partTranslation;
+               if( endpoint.TryGetTranslation( untranslatedTextPart, out partTranslation ) )
+               {
+                  translations.Add( variableName, partTranslation );
+               }
+               else if( allowStartJob )
+               {
+                  // incomplete, must start job
+                  var context = new ParserTranslationContext( null, endpoint, translationResult, result );
+                  Translate( untranslatedTextPart, endpoint, context );
+               }
+            }
+            else
+            {
+               // the value will do
+               translations.Add( variableName, untranslatedTextPart );
+            }
+         }
+
+         if( result.Arguments.Count == translations.Count )
+         {
+            return result.Untemplate( translations );
+         }
+         else
+         {
+            return null; // could not perform complete translation
+         }
+      }
+
       /// <summary>
       /// Translates the string of a UI  text or queues it up to be translated
       /// by the HTTP translation service.
@@ -1156,7 +1102,10 @@ namespace XUnity.AutoTranslator.Plugin.Core
             string translation;
             if( TextCache.TryGetTranslation( textKey, out translation ) )
             {
-               QueueNewUntranslatedForClipboard( textKey );
+               if( context == null && !isSpammer )
+               {
+                  QueueNewUntranslatedForClipboard( textKey );
+               }
 
                if( !string.IsNullOrEmpty( translation ) )
                {
@@ -1224,7 +1173,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                      StartCoroutine(
                         WaitForTextStablization(
                            ui: ui,
-                           delay: 1.0f, // 1 second to prevent '1 second tickers' from getting translated
+                           delay: 0.9f, // 0.9 second to prevent '1 second tickers' from getting translated
                            maxTries: 60, // 50 tries, about 1 minute
                            currentTries: 0,
                            onMaxTriesExceeded: () =>
@@ -1297,7 +1246,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                                        {
                                           if( !Settings.IsShutdown || !endpoint.HasFailedDueToConsecutiveErrors )
                                           {
-                                             CreateTranslationJobFor( endpoint, ui, stabilizedTextKey, context );
+                                             CreateTranslationJobFor( endpoint, ui, stabilizedTextKey, null, context );
                                           }
                                        }
                                     }
@@ -1322,7 +1271,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                         if( !Settings.IsShutdown || !endpoint.HasFailedDueToConsecutiveErrors )
                         {
                            // once the text has stabilized, attempt to look it up
-                           CreateTranslationJobFor( endpoint, ui, textKey, context );
+                           CreateTranslationJobFor( endpoint, ui, textKey, null, context );
                         }
                      }
                   }
@@ -1331,7 +1280,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                      StartCoroutine(
                         WaitForTextStablization(
                            textKey: textKey,
-                           delay: 1.0f,
+                           delay: 0.9f,
                            onTextStabilized: () =>
                            {
                               // Lets try not to spam a service that might not be there...
@@ -1343,7 +1292,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                                  {
                                     if( !TextCache.TryGetTranslation( textKey, out translation ) )
                                     {
-                                       CreateTranslationJobFor( endpoint, ui, textKey, context );
+                                       CreateTranslationJobFor( endpoint, ui, textKey, null, context );
                                     }
                                  }
                               }
@@ -1368,8 +1317,6 @@ namespace XUnity.AutoTranslator.Plugin.Core
             var untranslatedTextPart = kvp.Value;
             if( !string.IsNullOrEmpty( untranslatedTextPart ) && TextCache.IsTranslatable( untranslatedTextPart ) && IsBelowMaxLength( untranslatedTextPart ) )
             {
-               // FIXME: Only allow use of 'local cache' of endpoint?? Otherwise translation will bleed between endpoints!
-
                string partTranslation;
                if( TextCache.TryGetTranslation( untranslatedTextPart, out partTranslation ) )
                {
@@ -1378,7 +1325,7 @@ namespace XUnity.AutoTranslator.Plugin.Core
                else if( allowStartJob )
                {
                   // incomplete, must start job
-                  var context = new ParserTranslationContext( ui, result );
+                  var context = new ParserTranslationContext( ui, TranslationManager.CurrentEndpoint, null, result );
                   TranslateOrQueueWebJobImmediate( ui, untranslatedTextPart, null, false, true, context );
                }
             }
@@ -1526,11 +1473,13 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
             if( !Settings.IsShutdown )
             {
+               SpamChecker.Update();
+
                UpdateSpriteRenderers();
-               PeriodicResetFrameCheck();
                IncrementBatchOperations();
-               ResetThresholdTimerIfRequired();
                KickoffTranslations();
+
+               TranslationAggregatorWindow?.Update();
             }
 
             // perform this check every 100 frames!
@@ -1578,6 +1527,13 @@ namespace XUnity.AutoTranslator.Plugin.Core
                      MainWindow.IsShown = !MainWindow.IsShown;
                   }
                }
+               else if( isAltPressed && ( Input.GetKeyDown( KeyCode.Alpha1 ) || Input.GetKeyDown( KeyCode.Keypad1 ) ) )
+               {
+                  if( TranslationAggregatorWindow != null )
+                  {
+                     TranslationAggregatorWindow.IsShown = !TranslationAggregatorWindow.IsShown;
+                  }
+               }
             }
          }
          catch( Exception e )
@@ -1588,25 +1544,56 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
       void OnGUI()
       {
-         if( MainWindow != null )
+         try
          {
-            try
+            DisableAutoTranslator();
+
+            if( MainWindow != null )
             {
-               DisableAutoTranslator();
+               try
+               {
+                  if( MainWindow.IsShown ) MainWindow.OnGUI();
+               }
+               catch( Exception e )
+               {
+                  XuaLogger.Current.Error( e, "An error occurred in XUnity.AutoTranslator UI. Disabling the UI." );
 
-               if( MainWindow.IsShown ) MainWindow.OnGUI();
+                  MainWindow = null;
+               }
             }
-            catch( Exception e )
+
+            if( TranslationAggregatorWindow != null )
             {
-               XuaLogger.Current.Error( e, "An error occurred in XUnity.AutoTranslator UI. Disabling the UI." );
+               try
+               {
+                  if( TranslationAggregatorWindow.IsShown ) TranslationAggregatorWindow.OnGUI();
+               }
+               catch( Exception e )
+               {
+                  XuaLogger.Current.Error( e, "An error occurred in Translation Aggregator UI. Disabling the UI." );
 
-               MainWindow = null;
+                  TranslationAggregatorWindow = null;
+               }
             }
-            finally
+
+            if( TranslationAggregatorOptionsWindow != null )
             {
-               EnableAutoTranslator();
+               try
+               {
+                  if( TranslationAggregatorOptionsWindow.IsShown ) TranslationAggregatorOptionsWindow.OnGUI();
+               }
+               catch( Exception e )
+               {
+                  XuaLogger.Current.Error( e, "An error occurred in Translation Aggregator Options UI. Disabling the UI." );
+
+                  TranslationAggregatorOptionsWindow = null;
+               }
             }
          }
+         finally
+         {
+            EnableAutoTranslator();
+         }
       }
 
       private void RebootPlugin()
@@ -1627,31 +1614,44 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
       private void OnJobFailed( TranslationJob job )
       {
-         //foreach( var context in job.Contexts )
-         //{
-         //   // are all jobs within this context completed? If so, we can set the text
-         //   if( context.Jobs.Any( x => x.State == TranslationJobState.Failed ) )
-         //   {
-         //      // FIXME: Mark coroutine as failed!
-         //   }
-         //}
+         foreach( var translationResult in job.TranslationResults )
+         {
+            translationResult.SetErrorWithMessage( job.ErrorMessage ?? "Unknown error.", false );
+         }
 
-         //// FIXME: Mark coroutine related to job itself as failed
+         foreach( var context in job.Contexts )
+         {
+            var translationResult = context.TranslationResult;
+            if( translationResult != null )
+            {
+               // are all jobs within this context completed? If so, we can set the text
+               if( context.Jobs.Any( x => x.State == TranslationJobState.Failed ) )
+               {
+                  translationResult.SetErrorWithMessage( job.ErrorMessage ?? "Unknown error.", false );
+               }
+            }
+         }
       }
 
       private void OnJobCompleted( TranslationJob job )
       {
-         // FIXME: Complete potential Coroutines
+         if( job.SaveResultGlobally )
+         {
+            TextCache.AddTranslationToCache( job.Key, job.TranslatedText );
+         }
 
-         // Do NOT do this if the job is endpoint specific!!!
+         job.Endpoint.AddTranslationToCache( job.Key, job.TranslatedText );
 
-         // or we could ask, if the endpoint, the current endpoint????
-         if( job.SaveResult )
+         // fix translation results directly on jobs
+         foreach( var translationResult in job.TranslationResults )
          {
-            TextCache.AddTranslation( job.Key, job.TranslatedText );
             if( !string.IsNullOrEmpty( job.TranslatedText ) )
             {
-               TextCache.QueueNewTranslationForDisk( job.Key, job.TranslatedText );
+               translationResult.SetCompleted( job.TranslatedText, false );
+            }
+            else
+            {
+               translationResult.SetEmptyResponse( false );
             }
          }
 
@@ -1687,14 +1687,27 @@ namespace XUnity.AutoTranslator.Plugin.Core
                   var text = context.Component.GetText().TrimIfConfigured();
                   var result = context.Result;
                   Dictionary<string, string> translations = new Dictionary<string, string>();
-                  var translatedText = TranslateOrQueueWebJobImmediateByParserResult( context.Component, result, false );
+
+                  string translatedText;
+                  if( context.TranslationResult == null )
+                  {
+                     translatedText = TranslateOrQueueWebJobImmediateByParserResult( context.Component, result, false );
+                  }
+                  else
+                  {
+                     translatedText = TranslateByParserResult( context.Endpoint, result, null, false );
+                  }
 
                   if( !string.IsNullOrEmpty( translatedText ) )
                   {
-                     if( result.PersistCombinedResult && !TextCache.HasTranslated( context.Result.OriginalText ) && job.SaveResult )
+                     if( result.PersistCombinedResult )
                      {
-                        TextCache.AddTranslation( context.Result.OriginalText, translatedText );
-                        TextCache.QueueNewTranslationForDisk( context.Result.OriginalText, translatedText );
+                        if( job.SaveResultGlobally )
+                        {
+                           TextCache.AddTranslationToCache( context.Result.OriginalText, translatedText );
+                        }
+
+                        job.Endpoint.AddTranslationToCache( job.Key, job.TranslatedText );
                      }
 
                      if( text == result.OriginalText )
@@ -1702,11 +1715,22 @@ namespace XUnity.AutoTranslator.Plugin.Core
                         var info = context.Component.GetOrCreateTextTranslationInfo();
                         SetTranslatedText( context.Component, translatedText, info );
                      }
+
+                     if( context.TranslationResult != null )
+                     {
+                        context.TranslationResult.SetCompleted( translatedText, false );
+                     }
+                  }
+                  else
+                  {
+                     if( context.TranslationResult != null )
+                     {
+                        context.TranslationResult.SetEmptyResponse( false );
+                     }
                   }
                }
                catch( NullReferenceException )
                {
-
                }
             }
          }

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

@@ -23,7 +23,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
       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;
@@ -126,7 +126,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Configuration
             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 );
+            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 );

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

@@ -57,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" );

+ 66 - 9
src/XUnity.AutoTranslator.Plugin.Core/Endpoints/TranslationEndpointManager.cs

@@ -15,9 +15,12 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
       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;
+
       public TranslationEndpointManager( ITranslateEndpoint endpoint, Exception error )
       {
          Endpoint = endpoint;
@@ -28,6 +31,8 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
          _unstartedJobs = new Dictionary<string, TranslationJob>();
          _ongoingJobs = new Dictionary<string, TranslationJob>();
 
+         _translations = new Dictionary<string, string>();
+
          HasBatchLogicFailed = false;
          AvailableBatchOperations = Settings.MaxAvailableBatchOperations;
       }
@@ -54,6 +59,51 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
 
       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( TranslationKey key, string value )
+      {
+         var lookup = key.GetDictionaryLookupKey();
+         _translations[ lookup ] = value;
+      }
+
+      private void AddTranslation( string key, string value )
+      {
+         _translations[ key ] = value;
+      }
+
+      private void QueueNewTranslationForDisk( string key, string value )
+      {
+         // FIXME: Implement
+      }
+
+      public void AddTranslationToCache( TranslationKey key, string value )
+      {
+         AddTranslationToCache( key.GetDictionaryLookupKey(), value );
+      }
+
+      public void AddTranslationToCache( string key, string value )
+      {
+         if( !HasTranslated( key ) )
+         {
+            AddTranslation( key, value );
+            QueueNewTranslationForDisk( key, value );
+         }
+      }
+
+      private bool HasTranslated( string key )
+      {
+         return _translations.ContainsKey( key );
+      }
+
       public void HandleNextBatch()
       {
          try
@@ -81,6 +131,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
                {
                   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 );
                }
@@ -144,6 +195,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
             {
                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 );
             }
@@ -261,6 +313,8 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
             {
                var untranslatedText = job.Key.GetDictionaryLookupKey();
                job.State = TranslationJobState.Failed;
+               job.ErrorMessage = error;
+
                _ongoingJobs.Remove( untranslatedText );
                Manager.OngoingTranslations--;
 
@@ -312,16 +366,17 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
          XuaLogger.Current.Info( "Re-enabled batching." );
       }
 
-      public bool EnqueueTranslation( object ui, TranslationKey key, ParserTranslationContext context, bool checkOtherEndpoints )
+      public bool EnqueueTranslation( object ui, TranslationKey key, TranslationResult translationResult, ParserTranslationContext context )
       {
          var lookupKey = key.GetDictionaryLookupKey();
 
-         var added = AssociateWithExistingJobIfPossible( ui, lookupKey, context );
+         var added = AssociateWithExistingJobIfPossible( ui, lookupKey, translationResult, context );
          if( added )
          {
             return false;
          }
 
+         var checkOtherEndpoints = translationResult == null;
          if( checkOtherEndpoints )
          {
             var endpoints = Manager.ConfiguredEndpoints;
@@ -331,7 +386,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
                var endpoint = endpoints[ i ];
                if( endpoint == this ) continue;
 
-               added = endpoint.AssociateWithExistingJobIfPossible( ui, lookupKey, context );
+               added = endpoint.AssociateWithExistingJobIfPossible( ui, lookupKey, translationResult, context );
                if( added )
                {
                   return false;
@@ -341,23 +396,24 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
 
          XuaLogger.Current.Debug( "Queued: '" + lookupKey + "'" );
 
-         var newJob = new TranslationJob( this, key, true );
-         newJob.Associate( ui, context );
+         var saveResultGlobally = checkOtherEndpoints;
+         var newJob = new TranslationJob( this, key, saveResultGlobally );
+         newJob.Associate( ui, translationResult, context );
 
          return AddUnstartedJob( lookupKey, newJob );
       }
 
-      public bool AssociateWithExistingJobIfPossible( object ui, string key, ParserTranslationContext context )
+      public bool AssociateWithExistingJobIfPossible( object ui, string key, TranslationResult translationResult, ParserTranslationContext context )
       {
          if( _unstartedJobs.TryGetValue( key, out TranslationJob unstartedJob ) )
          {
-            unstartedJob.Associate( ui, context );
+            unstartedJob.Associate( ui, translationResult, context );
             return true;
          }
 
          if( _ongoingJobs.TryGetValue( key, out TranslationJob ongoingJob ) )
          {
-            ongoingJob.Associate( ui, context );
+            ongoingJob.Associate( ui, translationResult, context );
             return true;
          }
 
@@ -397,6 +453,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.Endpoints
          {
             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 );
          }

+ 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
    {

+ 88 - 8
src/XUnity.AutoTranslator.Plugin.Core/ITranslator.cs

@@ -3,12 +3,19 @@ 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( string untranslatedText ); // or just use callback?
+      TranslationResult Translate( TranslationEndpointManager endpoint, string untranslatedText );
    }
 
    class TranslationResult : IEnumerator
@@ -20,19 +27,92 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
       public string TranslatedText { get; private set; }
 
-      public void SetCompleted( string translatedText )
+      public string ErrorMessage { get; private set; }
+
+      public void SetCompleted( string translatedText, bool delay )
       {
-         TranslatedText = translatedText;
-         IsCompleted = true;
+         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 );
+      }
 
-         Completed?.Invoke( translatedText );
+      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." );
+         }
       }
 
-      public void SetError()
+      private IEnumerator SetCompletedAfterDelay( string translatedText )
       {
-         IsCompleted = true;
+         yield return null;
+
+         SetCompletedInternal( translatedText );
+      }
+
+      private void SetCompletedInternal( string translatedText )
+      {
+         TranslatedText = translatedText;
 
-         Error?.Invoke( "Oh no!" );
+         try
+         {
+            Completed?.Invoke( translatedText );
+         }
+         catch( Exception e )
+         {
+            XuaLogger.Current.Error( e, "An error occurred while notifying of translation completion." );
+         }
       }
 
       public object Current => null;

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

@@ -7,19 +7,23 @@ namespace XUnity.AutoTranslator.Plugin.Core
 {
    internal class ParserTranslationContext
    {
-      public ParserTranslationContext( object component, ParserResult result )
+      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 => Jobs.FirstOrDefault()?.Endpoint;
+      public TranslationEndpointManager Endpoint { get; private set; }
    }
 }

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

@@ -2,16 +2,227 @@
 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;
+         }
+      }
    }
 }

+ 14 - 15
src/XUnity.AutoTranslator.Plugin.Core/TextTranslationCache.cs

@@ -148,42 +148,41 @@ namespace XUnity.AutoTranslator.Plugin.Core
          }
       }
 
-      internal bool HasTranslated( string key )
+      private bool HasTranslated( string key )
       {
          return _translations.ContainsKey( key );
       }
 
-      internal bool IsTranslation( string translation )
+      private bool IsTranslation( string translation )
       {
          return _reverseTranslations.ContainsKey( translation );
       }
 
-      internal void AddTranslation( string key, string value )
+      private 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 )
+      private void QueueNewTranslationForDisk( string key, string value )
       {
          lock( _writeToFileSync )
          {
-            _newTranslations[ key.GetDictionaryLookupKey() ] = value;
+            _newTranslations[ key ] = value;
          }
       }
 
-      internal void QueueNewTranslationForDisk( string key, string value )
+      internal void AddTranslationToCache( TranslationKey key, string value )
       {
-         lock( _writeToFileSync )
+         AddTranslation( key.GetDictionaryLookupKey(), value );
+      }
+
+      internal void AddTranslationToCache( string key, string value )
+      {
+         if( !HasTranslated( key ) )
          {
-            _newTranslations[ key ] = value;
+            AddTranslation( key, value );
+            QueueNewTranslationForDisk( key, value );
          }
       }
 

+ 13 - 3
src/XUnity.AutoTranslator.Plugin.Core/TranslationJob.cs

@@ -15,13 +15,14 @@ namespace XUnity.AutoTranslator.Plugin.Core
       {
          Endpoint = endpoint;
          Key = key;
-         SaveResult = saveResult;
+         SaveResultGlobally = saveResult;
 
          Components = new List<object>();
          Contexts = new HashSet<ParserTranslationContext>();
+         TranslationResults = new HashSet<TranslationResult>();
       }
 
-      public bool SaveResult { get; private set; }
+      public bool SaveResultGlobally { get; private set; }
 
       public TranslationEndpointManager Endpoint { get; private set; }
 
@@ -29,13 +30,17 @@ namespace XUnity.AutoTranslator.Plugin.Core
 
       public List<object> Components { 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 void Associate( object ui, ParserTranslationContext context )
+      public void Associate( object ui, TranslationResult translationResult, ParserTranslationContext context )
       {
          if( context != null )
          {
@@ -48,6 +53,11 @@ namespace XUnity.AutoTranslator.Plugin.Core
             {
                Components.Add( ui );
             }
+
+            if( translationResult != null )
+            {
+               TranslationResults.Add( translationResult );
+            }
          }
       }
    }

+ 27 - 64
src/XUnity.AutoTranslator.Plugin.Core/UI/AggregatedTranslationViewModel.cs

@@ -1,86 +1,49 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
 using System.Linq;
 using System.Text;
-using XUnity.AutoTranslator.Plugin.Core.Endpoints;
+using UnityEngine;
 
 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 );
-      }
+      private List<Translation> _translations;
+      private TranslationAggregatorViewModel _parent;
+      private float _started;
 
-      public void StartAdditionnalTranslations()
+      public AggregatedTranslationViewModel( TranslationAggregatorViewModel parent, List<Translation> translations )
       {
-         foreach( var additionalTranslation in _additionalTranslations.Where( x => x.Key.IsEnabled ) )
-         {
-            var vm = additionalTranslation.Key;
-            var translation = additionalTranslation.Value;
-            if( translation == null )
-            {
-
-            }
-         }
+         _started = Time.realtimeSinceStartup;
+         _parent = parent;
+         _translations = translations;
+         AggregatedTranslations = parent.Endpoints.Select(
+            x => new IndividualTranslatorTranslationViewModel(
+               x,
+               new IndividualTranslationViewModel(
+                  x,
+                  translations.Select( y => new Translation( y.OriginalText, null ) ).ToList() ) ) ).ToList();
       }
-   }
 
-   class TranslationAggregatorViewModel
-   {
-      private LinkedList<AggregatedTranslationViewModel> _translations;
-      private LinkedListNode<AggregatedTranslationViewModel> _current;
-      private List<TranslatorViewModel> _endpoints;
+      public List<IndividualTranslatorTranslationViewModel> AggregatedTranslations { get; set; }
 
-      public TranslationAggregatorViewModel( IEnumerable<TranslationEndpointManager> endpoints )
-      {
-         _translations = new LinkedList<AggregatedTranslationViewModel>();
-         _endpoints = endpoints
-            .Where( x => x.Error == null )
-            .Select( x => new TranslatorViewModel( x ) )
-            .ToList();
-      }
+      public IEnumerable<string> DefaultTranslations => _translations.Select( x => x.TranslatedText );
 
-      public List<TranslatorViewModel> Endpoints => _endpoints;
+      public IEnumerable<string> OriginalTexts => _translations.Select( x => x.OriginalText );
 
-      public void OnNewTranslationAdded( TextTranslationInfo info )
+      public void Update()
       {
-         var vm = new AggregatedTranslationViewModel( this, info );
-
-         var previousLast = _translations.Last;
-
-         _translations.AddLast( vm );
-         if( _current == null )
+         if( _parent.IsShown )
          {
-            _current = _translations.Last;
-         }
-         else
-         {
-            if( _current == previousLast )
+            var timeSince = Time.realtimeSinceStartup - _started;
+            if( timeSince > 1.0f )
             {
-               _current = _translations.Last;
+               foreach( var additionTranslation in AggregatedTranslations )
+               {
+                  additionTranslation.Translation.Update();
+               }
             }
          }
       }
    }
-
-   class TranslatorViewModel
-   {
-      private TranslationEndpointManager _endpoint;
-
-      public TranslatorViewModel( TranslationEndpointManager endpoint )
-      {
-         _endpoint = endpoint;
-         IsEnabled = false; // initialize from configuration...
-      }
-
-      public bool IsEnabled { get; set; }
-   }
 }

+ 7 - 6
src/XUnity.AutoTranslator.Plugin.Core/UI/DropdownGUI.cs

@@ -3,23 +3,24 @@ using UnityEngine;
 
 namespace XUnity.AutoTranslator.Plugin.Core.UI
 {
+
    internal class DropdownGUI<TDropdownOptionViewModel, TSelection>
       where TDropdownOptionViewModel : DropdownOptionViewModel<TSelection>
    {
 
-      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 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, IEnumerable<TDropdownOptionViewModel> options )
       {
          _x = x;
          _y = y;
@@ -64,7 +65,7 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
          }
       }
 
-      private Vector2 ShowDropdown( int x, int y, int width, GUIStyle buttonStyle, Vector2 scrollPosition )
+      private Vector2 ShowDropdown( float x, float y, float width, GUIStyle buttonStyle, Vector2 scrollPosition )
       {
          var rect = GUIUtil.R( x, y, width, _options.Count * GUIUtil.RowHeight > MaxHeight ? MaxHeight : _options.Count * GUIUtil.RowHeight );
 

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

@@ -8,14 +8,20 @@ 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
+      };
+
       public static readonly GUIStyle LabelCenter = new GUIStyle( GUI.skin.label )
       {
          alignment = TextAnchor.UpperCenter,
@@ -59,7 +65,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()
       {

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

@@ -0,0 +1,64 @@
+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 Update()
+      {
+         if( _translator.IsEnabled )
+         {
+            if( !_hasStartedTranslation )
+            {
+               _hasStartedTranslation = true;
+
+               foreach( var translation in _translations )
+               {
+                  translation.PerformTranslation( _translator.Endpoint );
+               }
+            }
+
+            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; }
+   }
+}

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

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

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

@@ -0,0 +1,93 @@
+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 = 300;
+
+      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.Endpoints.Select( x =>
+         new ToggleViewModel(
+            x.Endpoint.Endpoint.FriendlyName,
+            null,
+            null,
+            () => x.IsEnabled = !x.IsEnabled,
+            () => x.IsEnabled ) ).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 previousValue = vm.IsToggled();
+            var newValue = GUILayout.Toggle( previousValue, vm.Text );
+            if( previousValue != newValue )
+            {
+               vm.OnToggled();
+            }
+         }
+
+         GUILayout.EndScrollView();
+
+         GUILayout.Label( "Height per Translator" );
+
+         _viewModel.HeightPerTranslator = GUILayout.HorizontalSlider( _viewModel.HeightPerTranslator, 50, 300 );
+
+         GUI.DragWindow();
+
+      }
+   }
+}

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

@@ -0,0 +1,142 @@
+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( IEnumerable<TranslationEndpointManager> endpoints )
+      {
+         _translations = new LinkedList<AggregatedTranslationViewModel>();
+
+         HeightPerTranslator = 100; // TODO: Get from config
+         Endpoints = endpoints
+            .Where( x => x.Error == null )
+            .Select( x => new TranslatorViewModel( x ) )
+            .ToList();
+      }
+
+      public bool IsShown { get; set; }
+
+      public bool IsShowingOptions { get; set; }
+
+      public float HeightPerTranslator { get; set; }
+
+      public List<TranslatorViewModel> Endpoints { get; }
+
+      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 1000
+            if( _translations.Count >= 1000 )
+            {
+               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 const float WindowWidth = 400;
+
+      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, WindowWidth, WindowHeight );
+
+         _originalText = new ScrollPositioned();
+         _defaultTranslation = new ScrollPositioned();
+         _translationViews = viewModel.Endpoints.Select( x => new ScrollPositioned<TranslatorViewModel>( x ) ).ToArray();
+      }
+
+      public bool IsShown
+      {
+         get => _viewModel.IsShown;
+         set => _viewModel.IsShown = value;
+      }
+
+      private float WindowHeight => ( ( _viewModel.Endpoints.Count( x => x.IsEnabled ) + 2 ) * _viewModel.HeightPerTranslator ) + 30 + GUIUtil.LabelHeight + GUIUtil.ComponentSpacing;
+
+      public void OnGUI()
+      {
+         _windowRect.height = WindowHeight;
+         _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( WindowWidth - 22, 2, 20, 16 ), "X" ) )
+         {
+            IsShown = false;
+         }
+
+         var current = _viewModel.Current;
+         if( current != null )
+         {
+            DrawTextArea( posy, _originalText, "Original Text", current.OriginalTexts );
+            posy += _viewModel.HeightPerTranslator;
+
+            DrawTextArea( posy, _defaultTranslation, "Default Translation", current.DefaultTranslations );
+            posy += _viewModel.HeightPerTranslator;
+
+            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.HeightPerTranslator;
+               }
+            }
+         }
+         else
+         {
+            DrawTextArea( posy, _originalText, "Original Text", Empty );
+            posy += _viewModel.HeightPerTranslator;
+
+            DrawTextArea( posy, _defaultTranslation, "Default Translation", Empty );
+            posy += _viewModel.HeightPerTranslator;
+
+            for( int i = 0; i < _viewModel.Endpoints.Count; i++ )
+            {
+               var translator = _viewModel.Endpoints[ i ];
+               if( translator.IsEnabled )
+               {
+                  var scroller = _translationViews[ i ];
+
+                  DrawTextArea(
+                     posy,
+                     scroller,
+                     translator.Endpoint.Endpoint.FriendlyName,
+                     Empty );
+                  posy += _viewModel.HeightPerTranslator;
+               }
+            }
+         }
+
+         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( GUIUtil.HalfComponentSpacing * 4 + 75 * 3, 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, WindowWidth - GUIUtil.ComponentSpacing, GUIUtil.LabelHeight ), title );
+
+         posy += GUIUtil.LabelHeight + GUIUtil.HalfComponentSpacing;
+
+         float boxWidth = WindowWidth - GUIUtil.ComponentSpacing;
+         float boxHeight = _viewModel.HeightPerTranslator - 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; }
+   }
+}

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

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

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

@@ -10,33 +10,24 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
    internal class XuaWindow
    {
       private const int WindowId = 5464332;
-      private const int WindowHeight = 520;
-      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 float WindowHeight = 520;
+      private const float WindowWidth = 320;
 
       private Rect _windowRect = new Rect( 20, 20, WindowWidth, WindowHeight );
 
       private DropdownGUI<TranslatorDropdownOptionViewModel, TranslationEndpointManager> _endpointDropdown;
-      private List<ToggleViewModel> _toggles;
-      private List<TranslatorDropdownOptionViewModel> _endpointOptions;
-      private List<ButtonViewModel> _commandButtons;
-      private List<LabelViewModel> _labels;
+      private XuaViewModel _viewModel;
       private bool _isMouseDownOnWindow = false;
 
-      public bool IsShown { get; set; }
+      public bool IsShown
+      {
+         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()
@@ -66,26 +57,24 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
 
       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 );
@@ -96,14 +85,15 @@ 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;
@@ -113,9 +103,9 @@ namespace XUnity.AutoTranslator.Plugin.Core.UI
             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 ) )
@@ -130,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, TranslationEndpointManager>( col2x, endpointDropdownPosy, col2, _endpointOptions ) );
+         var endpointDropdown = _endpointDropdown ?? ( _endpointDropdown = new DropdownGUI<TranslatorDropdownOptionViewModel, TranslationEndpointManager>( col2x, endpointDropdownPosy, col2, _viewModel.EndpointOptions ) );
          endpointDropdown.OnGUI();
 
          GUI.Label( GUIUtil.R( col1x, posy, col12, GUIUtil.RowHeight * 5 ), GUI.tooltip, GUIUtil.LabelRich );