AutoTranslationPlugin.cs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  1. using System;
  2. using System.Collections;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Net;
  7. using System.Reflection;
  8. using System.Text;
  9. using System.Text.RegularExpressions;
  10. using System.Threading;
  11. using ExIni;
  12. using UnityEngine;
  13. using UnityEngine.UI;
  14. using System.Globalization;
  15. using XUnity.AutoTranslator.Plugin.Core.Extensions;
  16. using UnityEngine.EventSystems;
  17. using XUnity.AutoTranslator.Plugin.Core.Configuration;
  18. using XUnity.AutoTranslator.Plugin.Core.Utilities;
  19. using XUnity.AutoTranslator.Plugin.Core.Web;
  20. using XUnity.AutoTranslator.Plugin.Core.Hooks;
  21. using XUnity.AutoTranslator.Plugin.Core.Hooks.TextMeshPro;
  22. using XUnity.AutoTranslator.Plugin.Core.Hooks.UGUI;
  23. using XUnity.AutoTranslator.Plugin.Core.IMGUI;
  24. using XUnity.AutoTranslator.Plugin.Core.Hooks.NGUI;
  25. using UnityEngine.SceneManagement;
  26. using XUnity.AutoTranslator.Plugin.Core.Constants;
  27. using XUnity.AutoTranslator.Plugin.Core.Debugging;
  28. using Harmony;
  29. namespace XUnity.AutoTranslator.Plugin.Core
  30. {
  31. public class AutoTranslationPlugin : MonoBehaviour
  32. {
  33. /// <summary>
  34. /// Allow the instance to be accessed statically, as only one will exist.
  35. /// </summary>
  36. public static AutoTranslationPlugin Current;
  37. /// <summary>
  38. /// These are the currently running translation jobs (being translated by an http request).
  39. /// </summary>
  40. private List<TranslationJob> _completedJobs = new List<TranslationJob>();
  41. private Dictionary<string, TranslationJob> _unstartedJobs = new Dictionary<string, TranslationJob>();
  42. /// <summary>
  43. /// All the translations are stored in this dictionary.
  44. /// </summary>
  45. private Dictionary<string, string> _translations = new Dictionary<string, string>();
  46. /// <summary>
  47. /// These are the new translations that has not yet been persisted to the file system.
  48. /// </summary>
  49. private object _writeToFileSync = new object();
  50. private Dictionary<string, string> _newTranslations = new Dictionary<string, string>();
  51. private HashSet<string> _newUntranslated = new HashSet<string>();
  52. private HashSet<string> _translatedTexts = new HashSet<string>();
  53. /// <summary>
  54. /// Keeps track of things to copy to clipboard.
  55. /// </summary>
  56. private List<string> _textsToCopyToClipboardOrdered = new List<string>();
  57. private HashSet<string> _textsToCopyToClipboard = new HashSet<string>();
  58. private float _clipboardUpdated = Time.realtimeSinceStartup;
  59. /// <summary>
  60. /// The number of http translation errors that has occurred up until now.
  61. /// </summary>
  62. private int _consecutiveErrors = 0;
  63. /// <summary>
  64. /// This is a hash set that contains all Text components that is currently being worked on by
  65. /// the translation plugin.
  66. /// </summary>
  67. private HashSet<object> _ongoingOperations = new HashSet<object>();
  68. private HashSet<string> _startedOperationsForNonStabilizableComponents = new HashSet<string>();
  69. /// <summary>
  70. /// This function will check if there are symbols of a given language contained in a string.
  71. /// </summary>
  72. private Func<string, bool> _symbolCheck;
  73. private object _advEngine;
  74. private float? _nextAdvUpdate;
  75. private IKnownEndpoint _endpoint;
  76. private int[] _currentTranslationsQueuedPerSecondRollingWindow = new int[ Settings.TranslationQueueWatchWindow ];
  77. private float? _timeExceededThreshold;
  78. private bool _isInTranslatedMode = true;
  79. private bool _hooksEnabled = true;
  80. public void Initialize()
  81. {
  82. Current = this;
  83. Logger.Current = new ConsoleLogger();
  84. Settings.Configure();
  85. if( Settings.EnableConsole ) DebugConsole.Enable();
  86. HooksSetup.InstallHooks( Override_TextChanged );
  87. try
  88. {
  89. _endpoint = KnownEndpoints.FindEndpoint( Settings.ServiceEndpoint );
  90. }
  91. catch( Exception e )
  92. {
  93. Logger.Current.Error( e, "An unexpected error occurred during initialization of endpoint." );
  94. }
  95. _symbolCheck = TextHelper.GetSymbolCheck( Settings.FromLanguage );
  96. LoadTranslations();
  97. // start a thread that will periodically removed unused references
  98. var t1 = new Thread( MaintenanceLoop );
  99. t1.IsBackground = true;
  100. t1.Start();
  101. // start a thread that will periodically save new translations
  102. var t2 = new Thread( SaveTranslationsLoop );
  103. t2.IsBackground = true;
  104. t2.Start();
  105. }
  106. private string[] GetTranslationFiles()
  107. {
  108. return Directory.GetFiles( Path.Combine( Config.Current.DataPath, Settings.TranslationDirectory ), $"*.txt", SearchOption.AllDirectories ) // FIXME: Add $"*{Language}.txt"
  109. .Union( new[] { Settings.AutoTranslationsFilePath } )
  110. .Select( x => x.Replace( "/", "\\" ) )
  111. .Distinct()
  112. .OrderBy( x => x )
  113. .ToArray();
  114. }
  115. private void MaintenanceLoop( object state )
  116. {
  117. while( true )
  118. {
  119. try
  120. {
  121. ObjectExtensions.Cull();
  122. }
  123. catch( Exception e )
  124. {
  125. Logger.Current.Error( e, "An unexpected error occurred while removing GC'ed resources." );
  126. }
  127. Thread.Sleep( 1000 * 60 );
  128. }
  129. }
  130. private void SaveTranslationsLoop( object state )
  131. {
  132. try
  133. {
  134. while( true )
  135. {
  136. if( _newTranslations.Count > 0 )
  137. {
  138. lock( _writeToFileSync )
  139. {
  140. if( _newTranslations.Count > 0 )
  141. {
  142. using( var stream = File.Open( Settings.AutoTranslationsFilePath, FileMode.Append, FileAccess.Write ) )
  143. using( var writer = new StreamWriter( stream, Encoding.UTF8 ) )
  144. {
  145. foreach( var kvp in _newTranslations )
  146. {
  147. writer.WriteLine( TextHelper.Encode( kvp.Key ) + '=' + TextHelper.Encode( kvp.Value ) );
  148. }
  149. writer.Flush();
  150. }
  151. _newTranslations.Clear();
  152. }
  153. }
  154. }
  155. else
  156. {
  157. Thread.Sleep( 5000 );
  158. }
  159. }
  160. }
  161. catch( Exception e )
  162. {
  163. Logger.Current.Error( e, "An error occurred while saving translations to disk." );
  164. }
  165. }
  166. /// <summary>
  167. /// Loads the translations found in Translation.{lang}.txt
  168. /// </summary>
  169. private void LoadTranslations()
  170. {
  171. try
  172. {
  173. lock( _writeToFileSync )
  174. {
  175. Directory.CreateDirectory( Path.Combine( Config.Current.DataPath, Settings.TranslationDirectory ) );
  176. Directory.CreateDirectory( Path.GetDirectoryName( Path.Combine( Config.Current.DataPath, Settings.OutputFile ) ) );
  177. var tab = new char[] { '\t' };
  178. var equals = new char[] { '=' };
  179. var splitters = new char[][] { tab, equals };
  180. foreach( var fullFileName in GetTranslationFiles() )
  181. {
  182. if( File.Exists( fullFileName ) )
  183. {
  184. string[] translations = File.ReadAllLines( fullFileName, Encoding.UTF8 );
  185. foreach( string translation in translations )
  186. {
  187. for( int i = 0 ; i < splitters.Length ; i++ )
  188. {
  189. var splitter = splitters[ i ];
  190. string[] kvp = translation.Split( splitter, StringSplitOptions.None );
  191. if( kvp.Length >= 2 )
  192. {
  193. string key = TextHelper.Decode( kvp[ 0 ].Trim() );
  194. string value = TextHelper.Decode( kvp[ 1 ].Trim() );
  195. if( !string.IsNullOrEmpty( key ) && !string.IsNullOrEmpty( value ) )
  196. {
  197. AddTranslation( key, value );
  198. break;
  199. }
  200. }
  201. }
  202. }
  203. }
  204. }
  205. }
  206. }
  207. catch( Exception e )
  208. {
  209. Logger.Current.Error( e, "An error occurred while loading translations." );
  210. }
  211. }
  212. private TranslationJob GetOrCreateTranslationJobFor( object ui, TranslationKeys key )
  213. {
  214. if( _unstartedJobs.TryGetValue( key.GetDictionaryLookupKey(), out TranslationJob job ) )
  215. {
  216. return job;
  217. }
  218. foreach( var completedJob in _completedJobs )
  219. {
  220. if( completedJob.Keys.GetDictionaryLookupKey() == key.GetDictionaryLookupKey() )
  221. {
  222. return completedJob;
  223. }
  224. }
  225. Logger.Current.Debug( "Queued translation for: " + key.GetDictionaryLookupKey() );
  226. job = new TranslationJob( key );
  227. job.OriginalSources.Add( ui );
  228. _unstartedJobs.Add( key.GetDictionaryLookupKey(), job );
  229. CheckThresholds();
  230. return job;
  231. }
  232. private void CheckThresholds()
  233. {
  234. if( _unstartedJobs.Count > Settings.MaxUnstartedJobs )
  235. {
  236. _unstartedJobs.Clear();
  237. _completedJobs.Clear();
  238. Settings.IsShutdown = true;
  239. Logger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxUnstartedJobs} queued for translations due to unknown reasons. Shutting down plugin." );
  240. }
  241. var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
  242. var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
  243. if( previousIdx != newIdx )
  244. {
  245. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
  246. }
  247. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ]++;
  248. var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
  249. var translationsPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
  250. if( translationsPerSecond > Settings.MaxTranslationsQueuedPerSecond )
  251. {
  252. if( !_timeExceededThreshold.HasValue )
  253. {
  254. _timeExceededThreshold = Time.time;
  255. }
  256. if( Time.time - _timeExceededThreshold.Value > Settings.MaxSecondsAboveTranslationThreshold )
  257. {
  258. _unstartedJobs.Clear();
  259. _completedJobs.Clear();
  260. Settings.IsShutdown = true;
  261. Logger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxTranslationsQueuedPerSecond} translations per seconds queued for a {Settings.MaxSecondsAboveTranslationThreshold} second period. Shutting down plugin." );
  262. }
  263. }
  264. else
  265. {
  266. _timeExceededThreshold = null;
  267. }
  268. }
  269. private void ResetThresholdTimerIfRequired()
  270. {
  271. var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
  272. var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
  273. if( previousIdx != newIdx )
  274. {
  275. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
  276. }
  277. var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
  278. var translationsPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
  279. if( translationsPerSecond <= Settings.MaxTranslationsQueuedPerSecond )
  280. {
  281. _timeExceededThreshold = null;
  282. }
  283. }
  284. private void AddTranslation( string key, string value )
  285. {
  286. _translations[ key ] = value;
  287. _translatedTexts.Add( value );
  288. }
  289. private void AddTranslation( TranslationKeys key, string value )
  290. {
  291. _translations[ key.GetDictionaryLookupKey() ] = value;
  292. _translatedTexts.Add( value );
  293. }
  294. private void QueueNewUntranslatedForClipboard( TranslationKeys key )
  295. {
  296. if( Settings.CopyToClipboard )
  297. {
  298. if( !_textsToCopyToClipboard.Contains( key.RelevantKey ) )
  299. {
  300. _textsToCopyToClipboard.Add( key.RelevantKey );
  301. _textsToCopyToClipboardOrdered.Add( key.RelevantKey );
  302. _clipboardUpdated = Time.realtimeSinceStartup;
  303. }
  304. }
  305. }
  306. private void QueueNewUntranslatedForDisk( TranslationKeys key )
  307. {
  308. _newUntranslated.Add( key.GetDictionaryLookupKey() );
  309. }
  310. private void QueueNewTranslationForDisk( TranslationKeys key, string value )
  311. {
  312. lock( _writeToFileSync )
  313. {
  314. _newTranslations[ key.GetDictionaryLookupKey() ] = value;
  315. }
  316. }
  317. private bool TryGetTranslation( TranslationKeys key, out string value )
  318. {
  319. return _translations.TryGetValue( key.GetDictionaryLookupKey(), out value );
  320. }
  321. public string Override_TextChanged( object ui, string text )
  322. {
  323. if( _hooksEnabled )
  324. {
  325. return TranslateOrQueueWebJob( ui, text, true );
  326. }
  327. return null;
  328. }
  329. public void Hook_TextChanged( object ui )
  330. {
  331. if( _hooksEnabled )
  332. {
  333. TranslateOrQueueWebJob( ui, null, false );
  334. }
  335. }
  336. public void Hook_TextInitialized( object ui )
  337. {
  338. if( _hooksEnabled )
  339. {
  340. TranslateOrQueueWebJob( ui, null, true );
  341. }
  342. }
  343. private void SetTranslatedText( object ui, string translatedText, TranslationKeys key, TranslationInfo info )
  344. {
  345. var untemplatedTranslatedText = key.Untemplate( translatedText );
  346. info?.SetTranslatedText( untemplatedTranslatedText );
  347. if( _isInTranslatedMode )
  348. {
  349. SetText( ui, untemplatedTranslatedText, true, info );
  350. }
  351. }
  352. /// <summary>
  353. /// Sets the text of a UI text, while ensuring this will not fire a text changed event.
  354. /// </summary>
  355. private void SetText( object ui, string text, bool isTranslated, TranslationInfo info )
  356. {
  357. if( !info?.IsCurrentlySettingText ?? true )
  358. {
  359. try
  360. {
  361. // TODO: Disable ANY Hook
  362. _hooksEnabled = false;
  363. if( info != null )
  364. {
  365. info.IsCurrentlySettingText = true;
  366. }
  367. ui.SetText( text );
  368. if( isTranslated )
  369. {
  370. info?.ResizeUI( ui );
  371. }
  372. else
  373. {
  374. info?.UnresizeUI( ui );
  375. }
  376. }
  377. catch( NullReferenceException )
  378. {
  379. // This is likely happened due to a scene change.
  380. }
  381. catch( Exception e )
  382. {
  383. Logger.Current.Error( e, "An error occurred while setting text on a component." );
  384. }
  385. finally
  386. {
  387. _hooksEnabled = true;
  388. if( info != null )
  389. {
  390. info.IsCurrentlySettingText = false;
  391. }
  392. }
  393. }
  394. }
  395. /// <summary>
  396. /// Determines if a text should be translated.
  397. /// </summary>
  398. private bool IsTranslatable( string str )
  399. {
  400. return _symbolCheck( str ) && str.Length <= Settings.MaxCharactersPerTranslation && !_translatedTexts.Contains( str );
  401. }
  402. public bool ShouldTranslate( object ui )
  403. {
  404. var cui = ui as Component;
  405. if( cui != null )
  406. {
  407. var go = cui.gameObject;
  408. var isDummy = go.IsDummy();
  409. if( isDummy )
  410. {
  411. return false;
  412. }
  413. var inputField = cui.gameObject.GetFirstComponentInSelfOrAncestor( Constants.Types.InputField )
  414. ?? cui.gameObject.GetFirstComponentInSelfOrAncestor( Constants.Types.TMP_InputField );
  415. return inputField == null;
  416. }
  417. return true;
  418. }
  419. private string TranslateOrQueueWebJob( object ui, string text, bool isAwakening )
  420. {
  421. var info = ui.GetTranslationInfo( isAwakening );
  422. if( !info?.IsAwake ?? false )
  423. {
  424. return null;
  425. }
  426. if( _ongoingOperations.Contains( ui ) )
  427. {
  428. return null;
  429. }
  430. var supportsStabilization = ui.SupportsStabilization();
  431. if( Settings.Delay == 0 || !supportsStabilization )
  432. {
  433. return TranslateOrQueueWebJobImmediate( ui, text, info, supportsStabilization );
  434. }
  435. else
  436. {
  437. StartCoroutine(
  438. DelayForSeconds( Settings.Delay, () =>
  439. {
  440. TranslateOrQueueWebJobImmediate( ui, text, info, supportsStabilization );
  441. } ) );
  442. }
  443. return null;
  444. }
  445. public static bool IsCurrentlySetting( TranslationInfo info )
  446. {
  447. if( info == null ) return false;
  448. return info.IsCurrentlySettingText;
  449. }
  450. /// <summary>
  451. /// Translates the string of a UI text or queues it up to be translated
  452. /// by the HTTP translation service.
  453. /// </summary>
  454. private string TranslateOrQueueWebJobImmediate( object ui, string text, TranslationInfo info, bool supportsStabilization )
  455. {
  456. // Get the trimmed text
  457. text = ( text ?? ui.GetText() ).Trim();
  458. // Ensure that we actually want to translate this text and its owning UI element.
  459. if( !string.IsNullOrEmpty( text ) && IsTranslatable( text ) && ShouldTranslate( ui ) && !IsCurrentlySetting( info ) )
  460. {
  461. info?.Reset( text );
  462. var textKey = new TranslationKeys( text, !supportsStabilization );
  463. // if we already have translation loaded in our _translatios dictionary, simply load it and set text
  464. string translation;
  465. if( TryGetTranslation( textKey, out translation ) )
  466. {
  467. QueueNewUntranslatedForClipboard( textKey );
  468. if( !string.IsNullOrEmpty( translation ) )
  469. {
  470. SetTranslatedText( ui, translation, textKey, info );
  471. return translation;
  472. }
  473. }
  474. else
  475. {
  476. if( supportsStabilization )
  477. {
  478. // if we dont know what text to translate it to, we need to figure it out.
  479. // this might take a while, so add the UI text component to the ongoing operations
  480. // list, so we dont start multiple operations for it, as its text might be constantly
  481. // changing.
  482. _ongoingOperations.Add( ui );
  483. // start a coroutine, that will execute once the string of the UI text has stopped
  484. // changing. For all texts except 'story' texts, this will add a delay for exactly
  485. // 0.5s to the translation. This is barely noticable.
  486. //
  487. // on the other hand, for 'story' texts, this will take the time that it takes
  488. // for the text to stop 'scrolling' in.
  489. try
  490. {
  491. StartCoroutine(
  492. WaitForTextStablization(
  493. ui: ui,
  494. delay: 1.0f, // 1 second to prevent '1 second tickers' from getting translated
  495. maxTries: 60, // 50 tries, about 1 minute
  496. currentTries: 0,
  497. onMaxTriesExceeded: () =>
  498. {
  499. _ongoingOperations.Remove( ui );
  500. },
  501. onTextStabilized: stabilizedText =>
  502. {
  503. _ongoingOperations.Remove( ui );
  504. if( !string.IsNullOrEmpty( stabilizedText ) && IsTranslatable( stabilizedText ) )
  505. {
  506. var stabilizedTextKey = new TranslationKeys( stabilizedText, false );
  507. QueueNewUntranslatedForClipboard( stabilizedTextKey );
  508. info?.Reset( stabilizedText );
  509. // once the text has stabilized, attempt to look it up
  510. if( TryGetTranslation( stabilizedTextKey, out translation ) )
  511. {
  512. if( !string.IsNullOrEmpty( translation ) )
  513. {
  514. SetTranslatedText( ui, translation, stabilizedTextKey, info );
  515. }
  516. }
  517. else
  518. {
  519. // Lets try not to spam a service that might not be there...
  520. if( _endpoint != null )
  521. {
  522. if( _consecutiveErrors < Settings.MaxErrors && !Settings.IsShutdown )
  523. {
  524. var job = GetOrCreateTranslationJobFor( ui, stabilizedTextKey );
  525. job.Components.Add( ui );
  526. }
  527. }
  528. else
  529. {
  530. QueueNewUntranslatedForDisk( stabilizedTextKey );
  531. }
  532. }
  533. }
  534. } ) );
  535. }
  536. catch( Exception )
  537. {
  538. _ongoingOperations.Remove( ui );
  539. }
  540. }
  541. else
  542. {
  543. if( !_startedOperationsForNonStabilizableComponents.Contains( textKey.GetDictionaryLookupKey() ) )
  544. {
  545. _startedOperationsForNonStabilizableComponents.Add( textKey.GetDictionaryLookupKey() );
  546. QueueNewUntranslatedForClipboard( textKey );
  547. // Lets try not to spam a service that might not be there...
  548. if( _endpoint != null )
  549. {
  550. if( _consecutiveErrors < Settings.MaxErrors && !Settings.IsShutdown )
  551. {
  552. GetOrCreateTranslationJobFor( ui, textKey );
  553. }
  554. }
  555. else
  556. {
  557. QueueNewUntranslatedForDisk( textKey );
  558. }
  559. }
  560. }
  561. }
  562. }
  563. return null;
  564. }
  565. /// <summary>
  566. /// Utility method that allows me to wait to call an action, until
  567. /// the text has stopped changing. This is important for 'story'
  568. /// mode text, which 'scrolls' into place slowly.
  569. /// </summary>
  570. public IEnumerator WaitForTextStablization( object ui, float delay, int maxTries, int currentTries, Action<string> onTextStabilized, Action onMaxTriesExceeded )
  571. {
  572. if( currentTries < maxTries ) // shortcircuit
  573. {
  574. var beforeText = ui.GetText();
  575. yield return new WaitForSeconds( delay );
  576. var afterText = ui.GetText();
  577. if( beforeText == afterText )
  578. {
  579. onTextStabilized( afterText.Trim() );
  580. }
  581. else
  582. {
  583. StartCoroutine( WaitForTextStablization( ui, delay, maxTries, currentTries + 1, onTextStabilized, onMaxTriesExceeded ) );
  584. }
  585. }
  586. else
  587. {
  588. onMaxTriesExceeded();
  589. }
  590. }
  591. public IEnumerator DelayForSeconds( float delay, Action onContinue )
  592. {
  593. yield return new WaitForSeconds( delay );
  594. onContinue();
  595. }
  596. public void Update()
  597. {
  598. try
  599. {
  600. if( _endpoint != null )
  601. {
  602. _endpoint.OnUpdate();
  603. }
  604. CopyToClipboard();
  605. if( !Settings.IsShutdown )
  606. {
  607. ResetThresholdTimerIfRequired();
  608. KickoffTranslations();
  609. FinishTranslations();
  610. if( _nextAdvUpdate.HasValue && Time.time > _nextAdvUpdate )
  611. {
  612. _nextAdvUpdate = null;
  613. UpdateUtageText();
  614. }
  615. }
  616. if( Input.anyKey )
  617. {
  618. if( Settings.EnablePrintHierarchy && ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.Y ) )
  619. {
  620. PrintObjects();
  621. }
  622. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.T ) )
  623. {
  624. ToggleTranslation();
  625. }
  626. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.D ) )
  627. {
  628. DumpUntranslated();
  629. }
  630. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.R ) )
  631. {
  632. ReloadTranslations();
  633. }
  634. }
  635. }
  636. catch( Exception e )
  637. {
  638. Logger.Current.Error( e, "An error occurred in Update callback. " );
  639. }
  640. }
  641. // create this as a field instead of local var, to prevent new creation on EVERY game loop
  642. private readonly List<string> _kickedOff = new List<string>();
  643. private void KickoffTranslations()
  644. {
  645. if( _endpoint == null ) return;
  646. foreach( var kvp in _unstartedJobs )
  647. {
  648. if( _endpoint.IsBusy ) break;
  649. var key = kvp.Key;
  650. var job = kvp.Value;
  651. _kickedOff.Add( key );
  652. // lets see if the text should still be translated before kicking anything off
  653. if( !job.AnyComponentsStillHasOriginalUntranslatedText() ) continue;
  654. StartCoroutine( _endpoint.Translate( job.Keys.GetDictionaryLookupKey(), Settings.FromLanguage, Settings.Language, translatedText =>
  655. {
  656. Settings.TranslationCount++;
  657. if( !Settings.IsShutdown )
  658. {
  659. if( Settings.TranslationCount > Settings.MaxTranslationsBeforeShutdown )
  660. {
  661. Settings.IsShutdown = true;
  662. Logger.Current.Error( $"Maximum translations ({Settings.MaxTranslationsBeforeShutdown}) per session reached. Shutting plugin down." );
  663. }
  664. }
  665. _consecutiveErrors = 0;
  666. if( Settings.ForceSplitTextAfterCharacters > 0 )
  667. {
  668. translatedText = translatedText.SplitToLines( Settings.ForceSplitTextAfterCharacters, '\n', ' ', ' ' );
  669. }
  670. job.TranslatedText = job.Keys.RepairTemplate( translatedText );
  671. if( !string.IsNullOrEmpty( translatedText ) )
  672. {
  673. QueueNewTranslationForDisk( job.Keys, translatedText );
  674. _completedJobs.Add( job );
  675. }
  676. },
  677. () =>
  678. {
  679. _consecutiveErrors++;
  680. if( !Settings.IsShutdown )
  681. {
  682. if( _consecutiveErrors > Settings.MaxErrors )
  683. {
  684. if( _endpoint.ShouldGetSecondChanceAfterFailure() )
  685. {
  686. Logger.Current.Warn( $"More than {Settings.MaxErrors} consecutive errors occurred. Entering fallback mode." );
  687. _consecutiveErrors = 0;
  688. }
  689. else
  690. {
  691. Settings.IsShutdown = true;
  692. Logger.Current.Error( $"More than {Settings.MaxErrors} consecutive errors occurred. Shutting down plugin." );
  693. }
  694. }
  695. }
  696. } ) );
  697. }
  698. for( int i = 0 ; i < _kickedOff.Count ; i++ )
  699. {
  700. _unstartedJobs.Remove( _kickedOff[ i ] );
  701. }
  702. _kickedOff.Clear();
  703. }
  704. private void FinishTranslations()
  705. {
  706. if( _completedJobs.Count > 0 )
  707. {
  708. for( int i = _completedJobs.Count - 1 ; i >= 0 ; i-- )
  709. {
  710. var job = _completedJobs[ i ];
  711. _completedJobs.RemoveAt( i );
  712. foreach( var component in job.Components )
  713. {
  714. // update the original text, but only if it has not been chaanged already for some reason (could be other translator plugin or game itself)
  715. var text = component.GetText().Trim();
  716. if( text == job.Keys.OriginalText )
  717. {
  718. var info = component.GetTranslationInfo( false );
  719. SetTranslatedText( component, job.TranslatedText, job.Keys, info );
  720. }
  721. }
  722. //Logger.Current.Debug( "FINISH: " + job.TranslatedText + ", " + string.Join( ", ", job.OriginalSources.Select( x => x.GetType().Name ).ToArray() ) );
  723. // Utage support
  724. if( Constants.Types.AdvCommand != null && Constants.Types.AdvEngine != null
  725. && job.OriginalSources.Any( x => Constants.Types.AdvCommand.IsAssignableFrom( x.GetType() ) ) )
  726. {
  727. _nextAdvUpdate = Time.time + 0.5f;
  728. }
  729. AddTranslation( job.Keys, job.TranslatedText );
  730. }
  731. }
  732. }
  733. private void UpdateUtageText()
  734. {
  735. if( _advEngine == null )
  736. {
  737. _advEngine = GameObject.FindObjectOfType( Constants.Types.AdvEngine );
  738. }
  739. if( _advEngine != null )
  740. {
  741. AccessTools.Method( Constants.Types.AdvEngine, "ChangeLanguage" )?.Invoke( _advEngine, new object[ 0 ] );
  742. }
  743. }
  744. private void ReloadTranslations()
  745. {
  746. LoadTranslations();
  747. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  748. {
  749. var info = kvp.Value as TranslationInfo;
  750. if( info != null && !string.IsNullOrEmpty( info.OriginalText ) )
  751. {
  752. var key = new TranslationKeys( info.OriginalText, false );
  753. if( TryGetTranslation( key, out string translatedText ) && !string.IsNullOrEmpty( translatedText ) )
  754. {
  755. SetTranslatedText( kvp.Key, translatedText, key, info );
  756. }
  757. }
  758. }
  759. }
  760. private string CalculateDumpFileName()
  761. {
  762. int idx = 0;
  763. string fileName = null;
  764. do
  765. {
  766. idx++;
  767. fileName = $"UntranslatedDump{idx}.txt";
  768. }
  769. while( File.Exists( fileName ) );
  770. return fileName;
  771. }
  772. private void DumpUntranslated()
  773. {
  774. if( _newUntranslated.Count > 0 )
  775. {
  776. using( var stream = File.Open( CalculateDumpFileName(), FileMode.Append, FileAccess.Write ) )
  777. using( var writer = new StreamWriter( stream, Encoding.UTF8 ) )
  778. {
  779. foreach( var untranslated in _newUntranslated )
  780. {
  781. writer.WriteLine( TextHelper.Encode( untranslated ) + '=' );
  782. }
  783. writer.Flush();
  784. }
  785. _newUntranslated.Clear();
  786. }
  787. }
  788. private void ToggleTranslation()
  789. {
  790. _isInTranslatedMode = !_isInTranslatedMode;
  791. if( _isInTranslatedMode )
  792. {
  793. // make sure we use the translated version of all texts
  794. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  795. {
  796. var ui = kvp.Key;
  797. try
  798. {
  799. if( ( ui as Component )?.gameObject?.activeSelf ?? false )
  800. {
  801. var info = (TranslationInfo)kvp.Value;
  802. if( info != null && info.IsTranslated )
  803. {
  804. SetText( ui, info.TranslatedText, true, info );
  805. }
  806. }
  807. }
  808. catch( Exception )
  809. {
  810. // not super pretty, no...
  811. ObjectExtensions.Remove( ui );
  812. }
  813. }
  814. }
  815. else
  816. {
  817. // make sure we use the original version of all texts
  818. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  819. {
  820. var ui = kvp.Key;
  821. try
  822. {
  823. if( ( ui as Component )?.gameObject?.activeSelf ?? false )
  824. {
  825. var info = (TranslationInfo)kvp.Value;
  826. if( info != null && info.IsTranslated )
  827. {
  828. SetText( ui, info.OriginalText, true, info );
  829. }
  830. }
  831. }
  832. catch( Exception )
  833. {
  834. // not super pretty, no...
  835. ObjectExtensions.Remove( ui );
  836. }
  837. }
  838. }
  839. }
  840. private void CopyToClipboard()
  841. {
  842. if( Settings.CopyToClipboard
  843. && _textsToCopyToClipboardOrdered.Count > 0
  844. && Time.realtimeSinceStartup - _clipboardUpdated > Settings.ClipboardDebounceTime )
  845. {
  846. try
  847. {
  848. var builder = new StringBuilder();
  849. foreach( var text in _textsToCopyToClipboardOrdered )
  850. {
  851. if( text.Length + builder.Length > Settings.MaxClipboardCopyCharacters ) break;
  852. builder.AppendLine( text );
  853. }
  854. TextEditor editor = (TextEditor)GUIUtility.GetStateObject( typeof( TextEditor ), GUIUtility.keyboardControl );
  855. editor.text = builder.ToString();
  856. editor.SelectAll();
  857. editor.Copy();
  858. }
  859. catch( Exception e )
  860. {
  861. Logger.Current.Error( e, "An error while copying text to clipboard." );
  862. }
  863. finally
  864. {
  865. _textsToCopyToClipboard.Clear();
  866. _textsToCopyToClipboardOrdered.Clear();
  867. }
  868. }
  869. }
  870. private void PrintObjects()
  871. {
  872. using( var stream = File.Open( Path.Combine( Environment.CurrentDirectory, "hierarchy.txt" ), FileMode.Create ) )
  873. using( var writer = new StreamWriter( stream ) )
  874. {
  875. foreach( var root in GetAllRoots() )
  876. {
  877. TraverseChildren( writer, root, "" );
  878. }
  879. writer.Flush();
  880. }
  881. }
  882. private IEnumerable<GameObject> GetAllRoots()
  883. {
  884. var objects = GameObject.FindObjectsOfType<GameObject>();
  885. foreach( var obj in objects )
  886. {
  887. if( obj.transform.parent == null )
  888. {
  889. yield return obj;
  890. }
  891. }
  892. }
  893. private void TraverseChildren( StreamWriter writer, GameObject obj, string identation )
  894. {
  895. var layer = LayerMask.LayerToName( obj.gameObject.layer );
  896. var components = string.Join( ", ", obj.GetComponents<Component>().Select( x => x.GetType().Name ).ToArray() );
  897. var line = string.Format( "{0,-50} {1,100}",
  898. identation + obj.gameObject.name + " [" + layer + "]",
  899. components );
  900. writer.WriteLine( line );
  901. for( int i = 0 ; i < obj.transform.childCount ; i++ )
  902. {
  903. var child = obj.transform.GetChild( i );
  904. TraverseChildren( writer, child.gameObject, identation + " " );
  905. }
  906. }
  907. }
  908. }