AutoTranslationPlugin.cs 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  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 XUnity.AutoTranslator.Plugin.Core.Batching;
  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 IKnownEndpoint _endpoint;
  74. private int[] _currentTranslationsQueuedPerSecondRollingWindow = new int[ Settings.TranslationQueueWatchWindow ];
  75. private float? _timeExceededThreshold;
  76. private float _translationsQueuedPerSecond;
  77. private bool _isInTranslatedMode = true;
  78. private bool _hooksEnabled = true;
  79. private bool _batchLogicHasFailed = false;
  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. foreach( var fullFileName in GetTranslationFiles() )
  178. {
  179. if( File.Exists( fullFileName ) )
  180. {
  181. string[] translations = File.ReadAllLines( fullFileName, Encoding.UTF8 );
  182. foreach( string translation in translations )
  183. {
  184. string[] kvp = translation.Split( new char[] { '=', '\t' }, StringSplitOptions.None );
  185. if( kvp.Length >= 2 )
  186. {
  187. string key = TextHelper.Decode( kvp[ 0 ].Trim() );
  188. string value = TextHelper.Decode( kvp[ 1 ].Trim() );
  189. if( !string.IsNullOrEmpty( key ) && !string.IsNullOrEmpty( value ) )
  190. {
  191. AddTranslation( key, value );
  192. }
  193. }
  194. }
  195. }
  196. }
  197. }
  198. }
  199. catch( Exception e )
  200. {
  201. Logger.Current.Error( e, "An error occurred while loading translations." );
  202. }
  203. }
  204. private TranslationJob GetOrCreateTranslationJobFor( TranslationKeys key )
  205. {
  206. if( _unstartedJobs.TryGetValue( key.GetDictionaryLookupKey(), out TranslationJob job ) )
  207. {
  208. return job;
  209. }
  210. foreach( var completedJob in _completedJobs )
  211. {
  212. if( completedJob.Keys.GetDictionaryLookupKey() == key.GetDictionaryLookupKey() )
  213. {
  214. return completedJob;
  215. }
  216. }
  217. Logger.Current.Debug( "Queued translation for: " + key.GetDictionaryLookupKey() );
  218. job = new TranslationJob( key );
  219. _unstartedJobs.Add( key.GetDictionaryLookupKey(), job );
  220. CheckThresholds();
  221. return job;
  222. }
  223. private void CheckThresholds()
  224. {
  225. if( _unstartedJobs.Count > Settings.MaxUnstartedJobs )
  226. {
  227. _unstartedJobs.Clear();
  228. _completedJobs.Clear();
  229. Settings.IsShutdown = true;
  230. Logger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxUnstartedJobs} queued for translations due to unknown reasons. Shutting down plugin." );
  231. }
  232. var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
  233. var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
  234. if( previousIdx != newIdx )
  235. {
  236. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
  237. }
  238. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ]++;
  239. var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
  240. _translationsQueuedPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
  241. if( _translationsQueuedPerSecond > Settings.MaxTranslationsQueuedPerSecond )
  242. {
  243. if( !_timeExceededThreshold.HasValue )
  244. {
  245. _timeExceededThreshold = Time.time;
  246. }
  247. if( Time.time - _timeExceededThreshold.Value > Settings.MaxSecondsAboveTranslationThreshold )
  248. {
  249. _unstartedJobs.Clear();
  250. _completedJobs.Clear();
  251. Settings.IsShutdown = true;
  252. Logger.Current.Error( $"SPAM DETECTED: More than {Settings.MaxTranslationsQueuedPerSecond} translations per seconds queued for a {Settings.MaxSecondsAboveTranslationThreshold} second period. Shutting down plugin." );
  253. }
  254. }
  255. else
  256. {
  257. _timeExceededThreshold = null;
  258. }
  259. }
  260. private void ResetThresholdTimerIfRequired()
  261. {
  262. var previousIdx = ( (int)( Time.time - Time.deltaTime ) ) % Settings.TranslationQueueWatchWindow;
  263. var newIdx = ( (int)Time.time ) % Settings.TranslationQueueWatchWindow;
  264. if( previousIdx != newIdx )
  265. {
  266. _currentTranslationsQueuedPerSecondRollingWindow[ newIdx ] = 0;
  267. }
  268. var translationsInWindow = _currentTranslationsQueuedPerSecondRollingWindow.Sum();
  269. _translationsQueuedPerSecond = (float)translationsInWindow / Settings.TranslationQueueWatchWindow;
  270. if( _translationsQueuedPerSecond <= Settings.MaxTranslationsQueuedPerSecond )
  271. {
  272. _timeExceededThreshold = null;
  273. }
  274. }
  275. private void AddTranslation( string key, string value )
  276. {
  277. _translations[ key ] = value;
  278. _translatedTexts.Add( value );
  279. }
  280. private void AddTranslation( TranslationKeys key, string value )
  281. {
  282. _translations[ key.GetDictionaryLookupKey() ] = value;
  283. _translatedTexts.Add( value );
  284. }
  285. private void QueueNewUntranslatedForClipboard( TranslationKeys key )
  286. {
  287. if( Settings.CopyToClipboard )
  288. {
  289. if( !_textsToCopyToClipboard.Contains( key.RelevantKey ) )
  290. {
  291. _textsToCopyToClipboard.Add( key.RelevantKey );
  292. _textsToCopyToClipboardOrdered.Add( key.RelevantKey );
  293. _clipboardUpdated = Time.realtimeSinceStartup;
  294. }
  295. }
  296. }
  297. private void QueueNewUntranslatedForDisk( TranslationKeys key )
  298. {
  299. _newUntranslated.Add( key.GetDictionaryLookupKey() );
  300. }
  301. private void QueueNewTranslationForDisk( TranslationKeys key, string value )
  302. {
  303. lock( _writeToFileSync )
  304. {
  305. _newTranslations[ key.GetDictionaryLookupKey() ] = value;
  306. }
  307. }
  308. private bool TryGetTranslation( TranslationKeys key, out string value )
  309. {
  310. return _translations.TryGetValue( key.GetDictionaryLookupKey(), out value );
  311. }
  312. private string Override_TextChanged( object ui, string text )
  313. {
  314. if( _hooksEnabled )
  315. {
  316. return TranslateOrQueueWebJob( ui, text, true );
  317. }
  318. return null;
  319. }
  320. public void Hook_TextChanged( object ui )
  321. {
  322. if( _hooksEnabled )
  323. {
  324. TranslateOrQueueWebJob( ui, null, false );
  325. }
  326. }
  327. public void Hook_TextInitialized( object ui )
  328. {
  329. if( _hooksEnabled )
  330. {
  331. TranslateOrQueueWebJob( ui, null, true );
  332. }
  333. }
  334. private void SetTranslatedText( object ui, string translatedText, TranslationKeys key, TranslationInfo info )
  335. {
  336. var untemplatedTranslatedText = key.Untemplate( translatedText );
  337. info?.SetTranslatedText( untemplatedTranslatedText );
  338. if( _isInTranslatedMode )
  339. {
  340. SetText( ui, untemplatedTranslatedText, true, info );
  341. }
  342. }
  343. /// <summary>
  344. /// Sets the text of a UI text, while ensuring this will not fire a text changed event.
  345. /// </summary>
  346. private void SetText( object ui, string text, bool isTranslated, TranslationInfo info )
  347. {
  348. if( !info?.IsCurrentlySettingText ?? true )
  349. {
  350. try
  351. {
  352. // TODO: Disable ANY Hook
  353. _hooksEnabled = false;
  354. if( info != null )
  355. {
  356. info.IsCurrentlySettingText = true;
  357. }
  358. ui.SetText( text );
  359. if( isTranslated )
  360. {
  361. info?.ResizeUI( ui );
  362. }
  363. else
  364. {
  365. info?.UnresizeUI( ui );
  366. }
  367. }
  368. catch( NullReferenceException )
  369. {
  370. // This is likely happened due to a scene change.
  371. }
  372. catch( Exception e )
  373. {
  374. Logger.Current.Error( e, "An error occurred while setting text on a component." );
  375. }
  376. finally
  377. {
  378. _hooksEnabled = true;
  379. if( info != null )
  380. {
  381. info.IsCurrentlySettingText = false;
  382. }
  383. }
  384. }
  385. }
  386. /// <summary>
  387. /// Determines if a text should be translated.
  388. /// </summary>
  389. private bool IsTranslatable( string str )
  390. {
  391. return _symbolCheck( str ) && str.Length <= Settings.MaxCharactersPerTranslation && !_translatedTexts.Contains( str );
  392. }
  393. public bool ShouldTranslate( object ui )
  394. {
  395. var cui = ui as Component;
  396. if( cui != null )
  397. {
  398. var go = cui.gameObject;
  399. var isDummy = go.IsDummy();
  400. if( isDummy )
  401. {
  402. return false;
  403. }
  404. var inputField = cui.gameObject.GetFirstComponentInSelfOrAncestor( Constants.Types.InputField )
  405. ?? cui.gameObject.GetFirstComponentInSelfOrAncestor( Constants.Types.TMP_InputField );
  406. return inputField == null;
  407. }
  408. return true;
  409. }
  410. private string TranslateOrQueueWebJob( object ui, string text, bool isAwakening )
  411. {
  412. var info = ui.GetTranslationInfo( isAwakening );
  413. if( !info?.IsAwake ?? false )
  414. {
  415. return null;
  416. }
  417. if( _ongoingOperations.Contains( ui ) )
  418. {
  419. return null;
  420. }
  421. var supportsStabilization = SupportsStabilization( ui );
  422. if( Settings.Delay == 0 || !supportsStabilization )
  423. {
  424. return TranslateOrQueueWebJobImmediate( ui, text, info, supportsStabilization );
  425. }
  426. else
  427. {
  428. StartCoroutine(
  429. DelayForSeconds( Settings.Delay, () =>
  430. {
  431. TranslateOrQueueWebJobImmediate( ui, text, info, supportsStabilization );
  432. } ) );
  433. }
  434. return null;
  435. }
  436. public static bool IsCurrentlySetting( TranslationInfo info )
  437. {
  438. if( info == null ) return false;
  439. return info.IsCurrentlySettingText;
  440. }
  441. /// <summary>
  442. /// Translates the string of a UI text or queues it up to be translated
  443. /// by the HTTP translation service.
  444. /// </summary>
  445. private string TranslateOrQueueWebJobImmediate( object ui, string text, TranslationInfo info, bool supportsStabilization )
  446. {
  447. // Get the trimmed text
  448. text = ( text ?? ui.GetText() ).Trim();
  449. // Ensure that we actually want to translate this text and its owning UI element.
  450. if( !string.IsNullOrEmpty( text ) && IsTranslatable( text ) && ShouldTranslate( ui ) && !IsCurrentlySetting( info ) )
  451. {
  452. info?.Reset( text );
  453. var textKey = new TranslationKeys( text, !supportsStabilization );
  454. // if we already have translation loaded in our _translatios dictionary, simply load it and set text
  455. string translation;
  456. if( TryGetTranslation( textKey, out translation ) )
  457. {
  458. QueueNewUntranslatedForClipboard( textKey );
  459. if( !string.IsNullOrEmpty( translation ) )
  460. {
  461. SetTranslatedText( ui, translation, textKey, info );
  462. return translation;
  463. }
  464. }
  465. else
  466. {
  467. if( supportsStabilization )
  468. {
  469. // if we dont know what text to translate it to, we need to figure it out.
  470. // this might take a while, so add the UI text component to the ongoing operations
  471. // list, so we dont start multiple operations for it, as its text might be constantly
  472. // changing.
  473. _ongoingOperations.Add( ui );
  474. // start a coroutine, that will execute once the string of the UI text has stopped
  475. // changing. For all texts except 'story' texts, this will add a delay for exactly
  476. // 0.5s to the translation. This is barely noticable.
  477. //
  478. // on the other hand, for 'story' texts, this will take the time that it takes
  479. // for the text to stop 'scrolling' in.
  480. try
  481. {
  482. StartCoroutine(
  483. WaitForTextStablization(
  484. ui: ui,
  485. delay: 1.0f, // 1 second to prevent '1 second tickers' from getting translated
  486. maxTries: 60, // 50 tries, about 1 minute
  487. currentTries: 0,
  488. onMaxTriesExceeded: () =>
  489. {
  490. _ongoingOperations.Remove( ui );
  491. },
  492. onTextStabilized: stabilizedText =>
  493. {
  494. _ongoingOperations.Remove( ui );
  495. if( !string.IsNullOrEmpty( stabilizedText ) && IsTranslatable( stabilizedText ) )
  496. {
  497. var stabilizedTextKey = new TranslationKeys( stabilizedText, false );
  498. QueueNewUntranslatedForClipboard( stabilizedTextKey );
  499. info?.Reset( stabilizedText );
  500. // once the text has stabilized, attempt to look it up
  501. if( TryGetTranslation( stabilizedTextKey, out translation ) )
  502. {
  503. if( !string.IsNullOrEmpty( translation ) )
  504. {
  505. SetTranslatedText( ui, translation, stabilizedTextKey, info );
  506. }
  507. }
  508. else
  509. {
  510. // Lets try not to spam a service that might not be there...
  511. if( _endpoint != null )
  512. {
  513. if( _consecutiveErrors < Settings.MaxErrors && !Settings.IsShutdown )
  514. {
  515. var job = GetOrCreateTranslationJobFor( stabilizedTextKey );
  516. job.Components.Add( ui );
  517. }
  518. }
  519. else
  520. {
  521. QueueNewUntranslatedForDisk( stabilizedTextKey );
  522. }
  523. }
  524. }
  525. } ) );
  526. }
  527. catch( Exception )
  528. {
  529. _ongoingOperations.Remove( ui );
  530. }
  531. }
  532. else
  533. {
  534. if( !_startedOperationsForNonStabilizableComponents.Contains( textKey.GetDictionaryLookupKey() ) )
  535. {
  536. _startedOperationsForNonStabilizableComponents.Add( textKey.GetDictionaryLookupKey() );
  537. QueueNewUntranslatedForClipboard( textKey );
  538. // Lets try not to spam a service that might not be there...
  539. if( _endpoint != null )
  540. {
  541. if( _consecutiveErrors < Settings.MaxErrors && !Settings.IsShutdown )
  542. {
  543. GetOrCreateTranslationJobFor( textKey );
  544. }
  545. }
  546. else
  547. {
  548. QueueNewUntranslatedForDisk( textKey );
  549. }
  550. }
  551. }
  552. }
  553. }
  554. return null;
  555. }
  556. public bool SupportsStabilization( object ui )
  557. {
  558. return !( ui is GUIContent );
  559. }
  560. /// <summary>
  561. /// Utility method that allows me to wait to call an action, until
  562. /// the text has stopped changing. This is important for 'story'
  563. /// mode text, which 'scrolls' into place slowly.
  564. /// </summary>
  565. public IEnumerator WaitForTextStablization( object ui, float delay, int maxTries, int currentTries, Action<string> onTextStabilized, Action onMaxTriesExceeded )
  566. {
  567. if( currentTries < maxTries ) // shortcircuit
  568. {
  569. var beforeText = ui.GetText();
  570. yield return new WaitForSeconds( delay );
  571. var afterText = ui.GetText();
  572. if( beforeText == afterText )
  573. {
  574. onTextStabilized( afterText.Trim() );
  575. }
  576. else
  577. {
  578. StartCoroutine( WaitForTextStablization( ui, delay, maxTries, currentTries + 1, onTextStabilized, onMaxTriesExceeded ) );
  579. }
  580. }
  581. else
  582. {
  583. onMaxTriesExceeded();
  584. }
  585. }
  586. public IEnumerator DelayForSeconds( float delay, Action onContinue )
  587. {
  588. yield return new WaitForSeconds( delay );
  589. onContinue();
  590. }
  591. public void Update()
  592. {
  593. try
  594. {
  595. if( _endpoint != null )
  596. {
  597. _endpoint.OnUpdate();
  598. }
  599. CopyToClipboard();
  600. if( !Settings.IsShutdown )
  601. {
  602. ResetThresholdTimerIfRequired();
  603. KickoffTranslations();
  604. FinishTranslations();
  605. }
  606. if( Input.anyKey )
  607. {
  608. if( Settings.EnablePrintHierarchy && ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.Y ) )
  609. {
  610. PrintObjects();
  611. }
  612. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.T ) )
  613. {
  614. ToggleTranslation();
  615. }
  616. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.D ) )
  617. {
  618. DumpUntranslated();
  619. }
  620. else if( ( Input.GetKey( KeyCode.LeftAlt ) || Input.GetKey( KeyCode.RightAlt ) ) && Input.GetKeyDown( KeyCode.R ) )
  621. {
  622. ReloadTranslations();
  623. }
  624. }
  625. }
  626. catch( Exception e )
  627. {
  628. Logger.Current.Error( e, "An error occurred in Update callback. " );
  629. }
  630. }
  631. // create this as a field instead of local var, to prevent new creation on EVERY game loop
  632. private readonly List<string> _kickedOff = new List<string>();
  633. private void KickoffTranslations()
  634. {
  635. if( _endpoint == null ) return;
  636. if( Settings.EnableBatching && _endpoint.SupportsLineSplitting && !_batchLogicHasFailed && _unstartedJobs.Count > 1 && _translationsQueuedPerSecond <= Settings.MaxTranslationsQueuedPerSecond )
  637. {
  638. while( _unstartedJobs.Count > 0 )
  639. {
  640. if( _endpoint.IsBusy ) break;
  641. var kvps = _unstartedJobs.Take( Settings.BatchSize ).ToList();
  642. var batch = new TranslationBatch();
  643. bool addedAny = false;
  644. foreach( var kvp in kvps )
  645. {
  646. var key = kvp.Key;
  647. var job = kvp.Value;
  648. _kickedOff.Add( key );
  649. batch.Add( job );
  650. if( !job.AnyComponentsStillHasOriginalUntranslatedText() ) continue;
  651. addedAny = true;
  652. }
  653. if( addedAny )
  654. {
  655. StartCoroutine( _endpoint.Translate( batch.GetFullTranslationKey(), Settings.FromLanguage, Settings.Language, translatedText => OnBatchTranslationCompleted( batch, translatedText ),
  656. () => OnTranslationFailed() ) );
  657. }
  658. }
  659. }
  660. else
  661. {
  662. foreach( var kvp in _unstartedJobs )
  663. {
  664. if( _endpoint.IsBusy ) break;
  665. var key = kvp.Key;
  666. var job = kvp.Value;
  667. _kickedOff.Add( key );
  668. // lets see if the text should still be translated before kicking anything off
  669. if( !job.AnyComponentsStillHasOriginalUntranslatedText() ) continue;
  670. StartCoroutine( _endpoint.Translate( job.Keys.GetDictionaryLookupKey(), Settings.FromLanguage, Settings.Language, translatedText => OnSingleTranslationCompleted( job, translatedText ),
  671. () => OnTranslationFailed() ) );
  672. }
  673. }
  674. for( int i = 0 ; i < _kickedOff.Count ; i++ )
  675. {
  676. _unstartedJobs.Remove( _kickedOff[ i ] );
  677. }
  678. _kickedOff.Clear();
  679. }
  680. public void OnBatchTranslationCompleted( TranslationBatch batch, string translatedTextBatch )
  681. {
  682. Settings.TranslationCount++;
  683. if( !Settings.IsShutdown )
  684. {
  685. if( Settings.TranslationCount > Settings.MaxTranslationsBeforeShutdown )
  686. {
  687. Settings.IsShutdown = true;
  688. Logger.Current.Error( $"Maximum translations ({Settings.MaxTranslationsBeforeShutdown}) per session reached. Shutting plugin down." );
  689. }
  690. }
  691. _consecutiveErrors = 0;
  692. var succeeded = batch.MatchWithTranslations( translatedTextBatch );
  693. if( succeeded )
  694. {
  695. foreach( var tracker in batch.Trackers )
  696. {
  697. var job = tracker.Job;
  698. var translatedText = tracker.RawTranslatedText;
  699. if( !string.IsNullOrEmpty( translatedText ) )
  700. {
  701. if( Settings.ForceSplitTextAfterCharacters > 0 )
  702. {
  703. translatedText = translatedText.SplitToLines( Settings.ForceSplitTextAfterCharacters, '\n', ' ', ' ' );
  704. }
  705. job.TranslatedText = job.Keys.RepairTemplate( translatedText );
  706. QueueNewTranslationForDisk( job.Keys, translatedText );
  707. _completedJobs.Add( job );
  708. }
  709. }
  710. }
  711. else
  712. {
  713. // might as well re-add all translation jobs, and never do this again!
  714. _batchLogicHasFailed = true;
  715. foreach( var tracker in batch.Trackers )
  716. {
  717. var key = tracker.Job.Keys.GetDictionaryLookupKey();
  718. if( !_unstartedJobs.ContainsKey( key ) )
  719. {
  720. _unstartedJobs[ key ] = tracker.Job;
  721. }
  722. }
  723. Logger.Current.Error( "A batch operation failed. Disabling batching and restarting failed jobs." );
  724. }
  725. }
  726. private void OnSingleTranslationCompleted( TranslationJob job, string translatedText )
  727. {
  728. Settings.TranslationCount++;
  729. if( !Settings.IsShutdown )
  730. {
  731. if( Settings.TranslationCount > Settings.MaxTranslationsBeforeShutdown )
  732. {
  733. Settings.IsShutdown = true;
  734. Logger.Current.Error( $"Maximum translations ({Settings.MaxTranslationsBeforeShutdown}) per session reached. Shutting plugin down." );
  735. }
  736. }
  737. _consecutiveErrors = 0;
  738. if( !string.IsNullOrEmpty( translatedText ) )
  739. {
  740. if( Settings.ForceSplitTextAfterCharacters > 0 )
  741. {
  742. translatedText = translatedText.SplitToLines( Settings.ForceSplitTextAfterCharacters, '\n', ' ', ' ' );
  743. }
  744. job.TranslatedText = job.Keys.RepairTemplate( translatedText );
  745. QueueNewTranslationForDisk( job.Keys, translatedText );
  746. _completedJobs.Add( job );
  747. }
  748. }
  749. private void OnTranslationFailed()
  750. {
  751. _consecutiveErrors++;
  752. if( !Settings.IsShutdown )
  753. {
  754. if( _consecutiveErrors > Settings.MaxErrors )
  755. {
  756. if( _endpoint.ShouldGetSecondChanceAfterFailure() )
  757. {
  758. Logger.Current.Warn( $"More than {Settings.MaxErrors} consecutive errors occurred. Entering fallback mode." );
  759. _consecutiveErrors = 0;
  760. }
  761. else
  762. {
  763. Settings.IsShutdown = true;
  764. Logger.Current.Error( $"More than {Settings.MaxErrors} consecutive errors occurred. Shutting down plugin." );
  765. _unstartedJobs.Clear();
  766. _completedJobs.Clear();
  767. }
  768. }
  769. }
  770. }
  771. private void FinishTranslations()
  772. {
  773. if( _completedJobs.Count > 0 )
  774. {
  775. for( int i = _completedJobs.Count - 1 ; i >= 0 ; i-- )
  776. {
  777. var job = _completedJobs[ i ];
  778. _completedJobs.RemoveAt( i );
  779. foreach( var component in job.Components )
  780. {
  781. // update the original text, but only if it has not been chaanged already for some reason (could be other translator plugin or game itself)
  782. var text = component.GetText().Trim();
  783. if( text == job.Keys.OriginalText )
  784. {
  785. var info = component.GetTranslationInfo( false );
  786. SetTranslatedText( component, job.TranslatedText, job.Keys, info );
  787. }
  788. }
  789. AddTranslation( job.Keys, job.TranslatedText );
  790. }
  791. }
  792. }
  793. private void ReloadTranslations()
  794. {
  795. LoadTranslations();
  796. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  797. {
  798. var info = kvp.Value as TranslationInfo;
  799. if( info != null && !string.IsNullOrEmpty( info.OriginalText ) )
  800. {
  801. var key = new TranslationKeys( info.OriginalText, false );
  802. if( TryGetTranslation( key, out string translatedText ) && !string.IsNullOrEmpty( translatedText ) )
  803. {
  804. SetTranslatedText( kvp.Key, translatedText, key, info );
  805. }
  806. }
  807. }
  808. }
  809. private string CalculateDumpFileName()
  810. {
  811. int idx = 0;
  812. string fileName = null;
  813. do
  814. {
  815. idx++;
  816. fileName = $"UntranslatedDump{idx}.txt";
  817. }
  818. while( File.Exists( fileName ) );
  819. return fileName;
  820. }
  821. private void DumpUntranslated()
  822. {
  823. if( _newUntranslated.Count > 0 )
  824. {
  825. using( var stream = File.Open( CalculateDumpFileName(), FileMode.Append, FileAccess.Write ) )
  826. using( var writer = new StreamWriter( stream, Encoding.UTF8 ) )
  827. {
  828. foreach( var untranslated in _newUntranslated )
  829. {
  830. writer.WriteLine( TextHelper.Encode( untranslated ) + '=' );
  831. }
  832. writer.Flush();
  833. }
  834. _newUntranslated.Clear();
  835. }
  836. }
  837. private void ToggleTranslation()
  838. {
  839. _isInTranslatedMode = !_isInTranslatedMode;
  840. if( _isInTranslatedMode )
  841. {
  842. // make sure we use the translated version of all texts
  843. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  844. {
  845. var ui = kvp.Key;
  846. try
  847. {
  848. if( ( ui as Component )?.gameObject?.activeSelf ?? false )
  849. {
  850. var info = (TranslationInfo)kvp.Value;
  851. if( info != null && info.IsTranslated )
  852. {
  853. SetText( ui, info.TranslatedText, true, info );
  854. }
  855. }
  856. }
  857. catch( Exception )
  858. {
  859. // not super pretty, no...
  860. ObjectExtensions.Remove( ui );
  861. }
  862. }
  863. }
  864. else
  865. {
  866. // make sure we use the original version of all texts
  867. foreach( var kvp in ObjectExtensions.GetAllRegisteredObjects() )
  868. {
  869. var ui = kvp.Key;
  870. try
  871. {
  872. if( ( ui as Component )?.gameObject?.activeSelf ?? false )
  873. {
  874. var info = (TranslationInfo)kvp.Value;
  875. if( info != null && info.IsTranslated )
  876. {
  877. SetText( ui, info.OriginalText, true, info );
  878. }
  879. }
  880. }
  881. catch( Exception )
  882. {
  883. // not super pretty, no...
  884. ObjectExtensions.Remove( ui );
  885. }
  886. }
  887. }
  888. }
  889. private void CopyToClipboard()
  890. {
  891. if( Settings.CopyToClipboard
  892. && _textsToCopyToClipboardOrdered.Count > 0
  893. && Time.realtimeSinceStartup - _clipboardUpdated > Settings.ClipboardDebounceTime )
  894. {
  895. try
  896. {
  897. var builder = new StringBuilder();
  898. foreach( var text in _textsToCopyToClipboardOrdered )
  899. {
  900. if( text.Length + builder.Length > Settings.MaxClipboardCopyCharacters ) break;
  901. builder.AppendLine( text );
  902. }
  903. TextEditor editor = (TextEditor)GUIUtility.GetStateObject( typeof( TextEditor ), GUIUtility.keyboardControl );
  904. editor.text = builder.ToString();
  905. editor.SelectAll();
  906. editor.Copy();
  907. }
  908. catch( Exception e )
  909. {
  910. Logger.Current.Error( e, "An error while copying text to clipboard." );
  911. }
  912. finally
  913. {
  914. _textsToCopyToClipboard.Clear();
  915. _textsToCopyToClipboardOrdered.Clear();
  916. }
  917. }
  918. }
  919. private void PrintObjects()
  920. {
  921. using( var stream = File.Open( Path.Combine( Environment.CurrentDirectory, "hierarchy.txt" ), FileMode.Create ) )
  922. using( var writer = new StreamWriter( stream ) )
  923. {
  924. foreach( var root in GetAllRoots() )
  925. {
  926. TraverseChildren( writer, root, "" );
  927. }
  928. writer.Flush();
  929. }
  930. }
  931. private IEnumerable<GameObject> GetAllRoots()
  932. {
  933. var objects = GameObject.FindObjectsOfType<GameObject>();
  934. foreach( var obj in objects )
  935. {
  936. if( obj.transform.parent == null )
  937. {
  938. yield return obj;
  939. }
  940. }
  941. }
  942. private void TraverseChildren( StreamWriter writer, GameObject obj, string identation )
  943. {
  944. var layer = LayerMask.LayerToName( obj.gameObject.layer );
  945. var components = string.Join( ", ", obj.GetComponents<Component>().Select( x => x.GetType().Name ).ToArray() );
  946. var line = string.Format( "{0,-50} {1,100}",
  947. identation + obj.gameObject.name + " [" + layer + "]",
  948. components );
  949. writer.WriteLine( line );
  950. for( int i = 0 ; i < obj.transform.childCount ; i++ )
  951. {
  952. var child = obj.transform.GetChild( i );
  953. TraverseChildren( writer, child.gameObject, identation + " " );
  954. }
  955. }
  956. }
  957. }