Arma Reforger Explorer 1.7.0.54
Arma Reforger Code Explorer by Zeroy - Thanks to MisterOutofTime
Loading...
Searching...
No Matches
TranslationPlugin.c
Go to the documentation of this file.
1#ifdef WORKBENCH
5// calling it delocalised localisationer would have been so much better
7 name: PLUGIN_NAME,
8 description: "Use a network API to translate selected/filtered rows",
9 shortcut: "Ctrl+Shift+T",
10 wbModules: { "LocalizationEditor" },
11 awesomeFontCode: 0xF1AB)]
12class TranslationPlugin : LocalizationEditorPlugin
13{
14 //
15 // TEMP
16 //
17
18 [Attribute(defvalue: "1", desc: "Disable global locales", category: "TEMP")]
19 protected bool m_bDisableLocalesGrouping;
20
21 //
22 // Selection
23 //
24
25 [Attribute(defvalue: ETranslationPlugin_EditedRowsMode.PROCESS_ALL.ToString(), uiwidget: UIWidgets.ComboBox, desc: "Define the behaviour of edited entries (among the selected ones)", enumType: ETranslationPlugin_EditedRowsMode, category: "Selection")]
26 protected ETranslationPlugin_EditedRowsMode m_eEditedEntriesMode;
27
28 //
29 // Actions
30 //
31
32 [Attribute(defvalue: "0", desc: "Overwrite existing translations if the entry itself is eligible to translation; otherwise only fill empty fields", category: "Actions")]
33 protected bool m_bOverwriteExistingTranslations;
34
35 [Attribute(defvalue: "1", desc: "Set entry's edited value as original value"
36 + "\ne.g Target_en_us takes the value of Target_en_us_edited and Target_en_us_edited gets cleared",
37 category: "Actions")]
38 protected bool m_bSetEditedAsOriginal;
39
40 //
41 // Advanced
42 //
43
44 [Attribute(desc: "[Mandatory] The translation server API URL, e.g http:/" + "/myserver/?mode=translate\nDefault protocol: " + DEFAULT_PROTOCOL + ":/" + "/", category: "Advanced")]
45 protected string m_sServerURL;
46
47 [Attribute(desc: "The translation server's authentication token, if needed", category: "Advanced")]
48 protected string m_sServerToken;
49
50 [Attribute(defvalue: "0", desc: "Show which generative AI model was used, cost etc (if available)", category: "Advanced")]
51 protected bool m_bDisplayAdvancedStatsOnSuccess;
52
53 [Attribute(desc: "[Mandatory] Locale-matching and special fields listing (see it as a string table item adapter)", category: "Advanced")]
54 protected ref TranslationPluginMatchConfig m_MatchConfig;
55
56 //
57 // Debug
58 //
59
60 [Attribute(defvalue: "0", desc: "Log when translation changes happen", category: "Debug")]
61 protected bool m_bLogChanges;
62
63 [Attribute(defvalue: "0", desc: "Log when network happens", category: "Debug")]
64 protected bool m_bLogNetwork;
65
66 [Attribute(defvalue: ETranslationPlugin_ProcessMode.PRODUCTION.ToString(), uiwidget: UIWidgets.ComboBox, enumType: ETranslationPlugin_ProcessMode, category: "Debug")]
67 protected ETranslationPlugin_ProcessMode m_eProcessMode;
68
69 // data that must be kept between REST send and receive (async)
70 protected ref map<string, ref TranslationPlugin_DataWrapper> m_mIdDataMap;
71 protected int m_iTranslationQueriesCount;
72 protected ref array<string> m_aGlobalLocales;
73
74 // REST check and variables
75 protected bool m_bWaitingOnRestAPI;
76 protected int m_iLastUsage;
77 protected ref RestCallback m_RestCallback;
78 protected ref WBProgressDialog m_ProgressBar;
79
80 protected static const string PLUGIN_NAME = "Translation plugin";
81
82 protected static const string SOURCE_ID = "SOURCE";
83 protected static const string SOURCE_EDITED_ID = "SOURCE_EDITED";
84
85 protected static const int MAX_DISPLAYED_IDS = 6;
86
87 // enums
88 protected static const int GENDER_MALE_INDEX = 1; // 0 (and any other value) = NONE
89 protected static const int GENDER_FEMALE_INDEX = 2;
90 protected static const int GENDER_OTHER_INDEX = 3;
91
92 protected static const string GENDER_MALE_VALUE = "M";
93 protected static const string GENDER_FEMALE_VALUE = "F";
94 protected static const string GENDER_OTHER_VALUE = "O"; // see DICOM standards's 0010:0040 patient field
95
96 protected static const string PROTOCOL_SEPARATOR = ":/" + "/";
97 protected static const string DEFAULT_PROTOCOL = "https";
98 protected static const ref array<string> ACCEPTED_PROTOCOLS = { "http" + PROTOCOL_SEPARATOR, DEFAULT_PROTOCOL + PROTOCOL_SEPARATOR };
99
100 // token filter - ^[a-zA-Z0-9-._~+/=]+$
101 protected static const string TOKEN_WHITELIST = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~+/="; // QnD
102
103 //------------------------------------------------------------------------------------------------
104 protected override void Run()
105 {
106 if (!m_MatchConfig)
107 ResetConfig();
108
109 LocalizationEditor stringEditor = Workbench.GetModule(LocalizationEditor);
110 if (!stringEditor)
111 {
112 PrintDialog("LocalizationEditor is not available", PLUGIN_NAME, LogLevel.ERROR);
113 return;
114 }
115
116 BaseContainer stringTable = stringEditor.GetTable();
117 if (!stringTable)
118 {
119 PrintDialog("No file opened.", PLUGIN_NAME, LogLevel.WARNING);
120 return;
121 }
122
123 BaseContainerList stringTableItems = stringTable.GetObjectArray("Items");
124 if (!stringTableItems)
125 {
126 PrintDialog("Error within the opened file - no Items property found.", PLUGIN_NAME, LogLevel.ERROR);
127 return;
128 }
129
130 int stringTableItemsCount = stringTableItems.Count();
131 if (stringTableItemsCount < 1)
132 {
133 PrintDialog("The current file is empty.", PLUGIN_NAME, LogLevel.NORMAL);
134 return;
135 }
136
137 // waiting on REST API? (not too long)
138 if (m_bWaitingOnRestAPI)
139 {
140 PrintDialog("Please wait for the result", PLUGIN_NAME, LogLevel.NORMAL);
141 return;
142 }
143
144 array<int> selectedRows = {};
145 stringEditor.GetSelectedRows(selectedRows);
146 int potentialRowsCount = selectedRows.Count();
147
148 if (potentialRowsCount < 1)
149 {
150 stringEditor.GetFilteredRows(selectedRows);
151 potentialRowsCount = selectedRows.Count();
152 if (potentialRowsCount < 1 || potentialRowsCount >= stringTableItemsCount)
153 {
154 PrintDialog("No lines selected/filtered.", PLUGIN_NAME, LogLevel.NORMAL);
155 return;
156 }
157 }
158
159 if (!CheckFields(stringTableItems.Get(selectedRows[0])))
160 return; // CheckFields already has PrintDialogs
161
162 int start = System.GetTickCount();
163 map<string, ref TranslationPlugin_DataWrapper> idWrapperMap = GetIdWrapperMap(stringTableItems, selectedRows);
164 PrintFormat("%1 ms - Finding translation needs", System.GetTickCount(start), level: LogLevel.NORMAL);
165
166 int queriesCount = idWrapperMap.Count();
167 if (queriesCount < 1)
168 {
169 PrintDialog("No selected rows need translation following the set options.", PLUGIN_NAME, LogLevel.NORMAL);
170 return;
171 }
172
173 string message;
174 if (queriesCount == 1)
175 {
176 string id = idWrapperMap.GetKey(0);
177 if (id.IsEmpty())
178 id = "Unknown";
179
180 if (potentialRowsCount == 1)
181 message = "You are about to translate the \"" + id + "\" row. Continue?";
182 else
183 message = "You are about to only translate the \"" + id + "\" row out of " + potentialRowsCount + " rows. Continue?";
184 }
185 else
186 {
187 if (queriesCount == potentialRowsCount)
188 message = string.Format("You are about to translate the following %1 rows:\n", queriesCount);
189 else
190 message = string.Format("You are about to translate the following %1 out of %2 rows:\n", queriesCount, potentialRowsCount);
191
192 int count;
193 foreach (string id, TranslationPlugin_DataWrapper wrapper : idWrapperMap)
194 {
195 ++count; // will replace e.g the last wanted element by (...) in order to keep the good number of lines
196 if (count >= MAX_DISPLAYED_IDS)
197 {
198 message += "\n\t- (...)";
199 break;
200 }
201
202 if (id.IsEmpty())
203 message += "\n\t- Unknown";
204 else
205 message += "\n\t- " + id;
206 }
207
208 message += "\n\nContinue?";
209 }
210
211 message += "\n\nOptions:";
212 if (m_bOverwriteExistingTranslations)
213 message += "\n✔ Overwrite existing translations";
214 else
215 message += "\n✖ Overwrite existing translations";
216
217 if (m_bSetEditedAsOriginal)
218 {
219 if (m_MatchConfig.m_sDefaultSourceEdited.IsEmpty())
220 message += "\n✖ Set edited translation as translation source (Default Source Edited field is empty)";
221 else
222 message += "\n✔ Set edited translation as translation source";
223 }
224 else
225 {
226 message += "\n✖ Set edited translation as translation source";
227 }
228
229 string processMode = typename.EnumToString(ETranslationPlugin_ProcessMode, m_eProcessMode);
230 if (Workbench.ScriptDialog(string.Format("[%1] %2", processMode, PLUGIN_NAME), message, new WorkbenchDialog_OKCancel()) == 0)
231 return;
232
233 m_iLastUsage = System.GetTickCount();
234 m_iTranslationQueriesCount = queriesCount;
235
236 StartProcess(idWrapperMap);
237 }
238
239 //------------------------------------------------------------------------------------------------
243 protected bool CheckFields(notnull BaseContainer item)
244 {
245 array<string> wrongFields = {};
246 string tmp;
247 if (!item.Get(m_MatchConfig.m_sIdField, tmp))
248 wrongFields.Insert("Id Field (" + m_MatchConfig.m_sIdField + ")");
249
250 if (!item.Get(m_MatchConfig.m_sDefaultSource, tmp))
251 wrongFields.Insert("Default Source (" + m_MatchConfig.m_sDefaultSource + ")");
252
253 if (m_bSetEditedAsOriginal && !m_MatchConfig.m_sDefaultSourceEdited.IsEmpty() && !item.Get(m_MatchConfig.m_sDefaultSourceEdited, tmp))
254 wrongFields.Insert("Default Source Edited (" + m_MatchConfig.m_sDefaultSourceEdited + ")");
255
256 if (!wrongFields.IsEmpty())
257 {
258 string message = "Wrong fields have been detected in configuration (bad config used?)";
259 foreach (string wrongField : wrongFields)
260 {
261 message += "\n- Cannot find " + wrongField;
262 }
263
264 PrintDialog(message, PLUGIN_NAME, LogLevel.WARNING);
265 return false;
266 }
267
268 return true;
269 }
270
271 //------------------------------------------------------------------------------------------------
275 protected bool StartProcess(notnull map<string, ref TranslationPlugin_DataWrapper> idWrapperMap)
276 {
277 int count = idWrapperMap.Count();
278 if (count < 1)
279 return false;
280
281 Game game = GetGame();
282 if (!game)
283 {
284 Print("Cannot obtain game with GetGame()", LogLevel.ERROR);
285 return false;
286 }
287
288 RestApi restAPI = game.GetRestApi();
289 if (!restAPI)
290 {
291 Print("Cannot obtain REST API with game.GetRestApi()", LogLevel.ERROR);
292 return false;
293 }
294
295 bool areLocalesSame = !m_bDisableLocalesGrouping;
296
297 TranslationPluginRequest request = new TranslationPluginRequest();
298 request.locales = {};
299 request.locales.Copy(idWrapperMap.GetElement(0).m_Query.locales);
300 request.locales.Sort();
301 foreach (string id, TranslationPlugin_DataWrapper dataWrapper : idWrapperMap)
302 {
303 request.queries.Insert(dataWrapper.m_Query);
304 dataWrapper.m_Query.locales.Sort();
305 if (count > 1 && areLocalesSame && !AreLocalesEqual(request.locales, dataWrapper.m_Query.locales))
306 areLocalesSame = false;
307 }
308
309 if (areLocalesSame)
310 {
311 m_aGlobalLocales = request.locales;
312 foreach (string id, TranslationPlugin_DataWrapper dataWrapper : idWrapperMap)
313 {
314 dataWrapper.m_Query.locales = null;
315 }
316 }
317 else
318 {
319 request.locales = null;
320 }
321
322 m_mIdDataMap = idWrapperMap; // to survive async
323 if (m_eProcessMode == ETranslationPlugin_ProcessMode.PRODUCTION)
324 {
325 bool result = SendRequest(restAPI, request);
326 if (!result)
327 ResetRequestState();
328
329 return result;
330 }
331
332 m_bWaitingOnRestAPI = true;
333
334 switch (m_eProcessMode)
335 {
336 case ETranslationPlugin_ProcessMode.SIMULATE_SUCCESS: ProcessSuccessResponse(GetFakeResponse()); return true;
337
338 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_403: ProcessErrorResponse(HttpCode.HTTP_CODE_403, ERestResult.EREST_ERROR); break;
339 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_404: ProcessErrorResponse(HttpCode.HTTP_CODE_404, ERestResult.EREST_ERROR); break;
340 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_408: ProcessErrorResponse(HttpCode.HTTP_CODE_408, ERestResult.EREST_ERROR_TIMEOUT); break;
341 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_418: ProcessErrorResponse(HttpCode.HTTP_CODE_418, ERestResult.EREST_ERROR_UNKNOWN); break;
342 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_500: ProcessErrorResponse(HttpCode.HTTP_CODE_500, ERestResult.EREST_ERROR_SERVERERROR); break;
343 case ETranslationPlugin_ProcessMode.SIMULATE_ERROR_501: ProcessErrorResponse(HttpCode.HTTP_CODE_501, ERestResult.EREST_ERROR_NOTIMPLEMENTED); break;
344 }
345
346 return false;
347 }
348
349 //------------------------------------------------------------------------------------------------
350 protected bool SendRequest(notnull RestApi restAPI, notnull TranslationPluginRequest request)
351 {
352 string protocol, address, query;
353 if (!SplitURL(m_sServerURL, protocol, address, query))
354 {
355 Print("URL \"" + m_sServerURL + "\" is invalid.", LogLevel.ERROR);
356 return false;
357 }
358
359 string protocolAndAddress = protocol + address;
360
361 m_RestCallback = new RestCallback();
362 m_RestCallback.SetOnSuccess(REST_OnSuccess);
363 m_RestCallback.SetOnError(REST_OnError);
364
365 RestContext restContext = restAPI.GetContext(protocolAndAddress);
366 if (!restContext)
367 {
368 Print("Cannot obtain REST context for " + protocolAndAddress, LogLevel.ERROR);
369 return false;
370 }
371
372 if (!m_sServerToken.IsEmpty())
373 {
374 string sanitisedToken = SanitizeToken(m_sServerToken); // must be already sanitised by now
375 if (!sanitisedToken.IsEmpty())
376 restContext.SetHeaders("Authorization,Bearer " + sanitisedToken + ",Content-Type,application/json");
377 }
378
379 restContext.SetTimeout(300); // response can take long time
380
381 request.Pack();
382 string json = request.AsString();
383
384 if (m_bLogNetwork)
385 {
386 if (query.IsEmpty())
387 PrintFormat("Sending JSON by POST to %1, no query\nJSON: %2", protocolAndAddress, json, level: LogLevel.NORMAL);
388 else
389 PrintFormat("Sending JSON by POST to %1, query %2\nJSON: %3", protocolAndAddress, query, json, level: LogLevel.NORMAL);
390 }
391
392 int queriesCount = request.queries.Count();
393 int localesCount;
394 if (request.locales)
395 {
396 localesCount = request.locales.Count() * queriesCount;
397 }
398 else
399 {
400 foreach (TranslationPluginQuery translationQuery : request.queries)
401 {
402 localesCount += translationQuery.locales.Count();
403 }
404 }
405
406 m_ProgressBar = new WBProgressDialog(string.Format("Fetching %1 translation(s) for %2 row(s)...", localesCount, queriesCount), Workbench.GetModule(LocalizationEditor));
407 m_ProgressBar.SetProgress(0.42);
408
409 restContext.POST(m_RestCallback, query, json);
410 m_bWaitingOnRestAPI = true;
411
412 return true;
413 }
414
415 //------------------------------------------------------------------------------------------------
418 protected void REST_OnSuccess(RestCallback cb = null)
419 {
420 if (!cb)
421 {
422 PrintDialog("No REST callback returned!", PLUGIN_NAME, LogLevel.ERROR);
423 return;
424 }
425
426 if (cb != m_RestCallback)
427 return; // old request, do not treat
428
429 TranslationPluginResponse response;
430 string json = cb.GetData();
431 if (m_bLogNetwork)
432 Print("Received JSON: " + json, LogLevel.NORMAL);
433
434 response = new TranslationPluginResponse();
435 response.ExpandFromRAW(json);
436
437 ProcessSuccessResponse(response);
438 }
439
440 //------------------------------------------------------------------------------------------------
443 protected void ProcessSuccessResponse(notnull TranslationPluginResponse response)
444 {
445 LocalizationEditor stringEditor = Workbench.GetModule(LocalizationEditor);
446 if (!stringEditor)
447 {
448 PrintDialog("LocalizationEditor is not available", PLUGIN_NAME, LogLevel.ERROR);
449 ResetRequestState();
450 return;
451 }
452
453 int start = System.GetTickCount();
454 int modifiedRows = ApplyTranslations(stringEditor, response);
455 PrintFormat("%1 ms - Applying translations", System.GetTickCount(start), level: LogLevel.NORMAL);
456
457 if (modifiedRows > 0)
458 stringEditor.RefreshUI();
459
460 string message = string.Format(
461 "Modified %1 out of %2 selected/filtered lines - processing time %3",
462 modifiedRows,
463 m_iTranslationQueriesCount,
464 FormatDurationMs(System.GetTickCount(m_iLastUsage)));
465
466 if (m_bDisplayAdvancedStatsOnSuccess && response.meta)
467 {
468 string metaMessage;
469 if (!response.meta.model.IsEmpty())
470 metaMessage += "\nAI model: " + response.meta.model;
471
472 if (response.meta.usage)
473 {
474 if (response.meta.usage.prompt_tokens > 0 && response.meta.usage.completion_tokens > 0 && response.meta.usage.total_tokens > 0)
475 metaMessage += string.Format(
476 "\nTokens used: %1 (prompt) + %2 (completion) = %3 total",
477 response.meta.usage.prompt_tokens,
478 response.meta.usage.completion_tokens,
479 response.meta.usage.total_tokens);
480 else
481 if (response.meta.usage.total_tokens > 0)
482 metaMessage += "\nTokens used: " + response.meta.usage.total_tokens;
483 }
484
485 if (response.meta.total_cost > 0)
486 metaMessage += "\nTotal cost: " + response.meta.total_cost + response.meta.currency;
487
488 if (!metaMessage.IsEmpty())
489 message += "\n" + metaMessage;
490 }
491
492 ResetRequestState();
493
494 PrintDialog(message, PLUGIN_NAME, LogLevel.NORMAL);
495 }
496
497 //------------------------------------------------------------------------------------------------
500 protected void REST_OnError(RestCallback cb = null)
501 {
502 if (!cb)
503 {
504 PrintDialog("No REST callback returned!", PLUGIN_NAME, LogLevel.ERROR);
505 return;
506 }
507
508 if (cb != m_RestCallback)
509 return; // old request, do not treat
510
511 ProcessErrorResponse(cb.GetHttpCode(), cb.GetRestResult());
512 }
513
514 //------------------------------------------------------------------------------------------------
518 protected void ProcessErrorResponse(HttpCode errorCode, ERestResult restResult)
519 {
520 ResetRequestState();
521
522 string errorCodeStr = typename.EnumToString(HttpCode, errorCode);
523 int httpCode = errorCodeStr.ToInt(offset: 10); // remove "HTTP_CODE_"; HTTP_CODE_NULL returns 0
524
525 string errorName;
526 switch (httpCode)
527 {
528 case 301: errorName = "Moved Permanently"; break;
529 case 400: errorName = "Bad Request"; break;
530 case 401: errorName = "Unauthorized"; break;
531 case 403: errorName = "Forbidden"; break;
532 case 403: errorName = "Forbidden"; break;
533 case 404: errorName = "Not Found"; break;
534 case 408: errorName = "Request Timeout"; break;
535 case 410: errorName = "Gone"; break;
536 case 418: errorName = "I'm a teapot"; break;
537 case 500: errorName = "Internal Server Error"; break;
538 case 501: errorName = "Not Implemented"; break;
539 case 502: errorName = "Bad Gateway"; break;
540 case 503: errorName = "Service Unavailable"; break;
541 case 504: errorName = "Gateway Timeout"; break;
542 case 511: errorName = "Network Authentication Required"; break;
543 default: errorName = "Unknown - timeout/wrong target?"; break; // HTTP_CODE_NULL goes here
544 }
545
546 string message = string.Format(
547 "Error communicating with the server:\n\nURL: %1\nError: %2 %3\n\n(%4 %5)",
548 m_sServerURL,
549 httpCode, errorName,
550 errorCodeStr, typename.EnumToString(ERestResult, restResult));
551
552 PrintDialog(message, PLUGIN_NAME + " - " + errorName, LogLevel.ERROR);
553 }
554
555 //------------------------------------------------------------------------------------------------
560 protected map<string, ref TranslationPlugin_DataWrapper> GetIdWrapperMap(notnull BaseContainerList stringTableItems, notnull array<int> rowsToTranslate)
561 {
563 foreach (int rowToTranslate : rowsToTranslate)
564 {
565 BaseContainer item = stringTableItems.Get(rowToTranslate);
566 if (!item)
567 continue;
568
569 string id;
570 if (!item.Get(m_MatchConfig.m_sIdField, id))
571 {
572 PrintFormatDialog("Wrong ID field: \"%1\" is invalid", m_MatchConfig.m_sIdField, caption: PLUGIN_NAME, level: LogLevel.WARNING);
573 break;
574 }
575
576 if (!item.Get(m_MatchConfig.m_sIdField, id) || id.IsEmpty())
577 {
578 Print("Element at row " + rowToTranslate + " does not have an ID", LogLevel.WARNING);
579 continue;
580 }
581
582 if (!item.GetClassName().ToType() || !item.GetClassName().ToType().IsInherited(ScriptStringTableItem))
583 {
584 Print("Element " + id + " is not ScriptStringTableItem", LogLevel.WARNING);
585 continue;
586 }
587
588 TranslationPlugin_DataWrapper dataWrapper = CreateDataWrapper(id, item);
589 if (!dataWrapper) // error or no need to translate
590 continue;
591
592 if (result.Contains(id))
593 {
594 Print("ID " + id + " duplicate detected - skipping", LogLevel.WARNING);
595 continue;
596 }
597
598 result.Insert(id, dataWrapper);
599 }
600
601 return result;
602 }
603
604 //------------------------------------------------------------------------------------------------
608 protected TranslationPlugin_DataWrapper CreateDataWrapper(string id, notnull BaseContainer item)
609 {
610 if (id.IsEmpty())
611 return null;
612
613 bool skip;
614 item.Get(m_MatchConfig.m_sSkipField, skip);
615 if (skip)
616 return null;
617
618 map<string, string> translations = GetAllItemTranslations(item);
619 if (!translations)
620 return null;
621
622 string text;
623 bool isUpdate = true;
624 if (translations.Find(SOURCE_EDITED_ID, text) && !text.IsEmpty())
625 {
626 if (m_eEditedEntriesMode == ETranslationPlugin_EditedRowsMode.PROCESS_UNEDITED_ONLY)
627 return null;
628 }
629 else
630 {
631 if (m_eEditedEntriesMode == ETranslationPlugin_EditedRowsMode.PROCESS_EDITED_ONLY)
632 return null;
633
634 isUpdate = false;
635 if (!translations.Find(SOURCE_ID, text) || text.IsEmpty())
636 return null; // no source = no translation
637 }
638
639 array<string> locales = {};
640 foreach (string key, string value : translations)
641 {
642 if (key == SOURCE_ID || key == SOURCE_EDITED_ID)
643 continue;
644
645 if (isUpdate || m_bOverwriteExistingTranslations || value.IsEmpty())
646 locales.Insert(key);
647 }
648
649 if (locales.IsEmpty()) // all filled, no translation needed
650 return null;
651
652 TranslationPlugin_DataWrapper result = new TranslationPlugin_DataWrapper();
653 result.m_bIsUpdate = isUpdate;
654 result.m_Item = item;
655
656 result.m_Query = new TranslationPluginQuery();
657 result.m_Query.id = id;
658 result.m_Query.text = text;
659
660 result.m_Query.locales.InsertAll(locales);
661
662 TranslationRequestMeta meta = new TranslationRequestMeta();
663
664 int gender;
665 if (item.Get(m_MatchConfig.m_sGenderField, gender))
666 {
667 if (gender == GENDER_MALE_INDEX)
668 meta.gender = GENDER_MALE_VALUE;
669 else
670 if (gender == GENDER_FEMALE_INDEX)
671 meta.gender = GENDER_FEMALE_VALUE;
672 else
673 if (gender == GENDER_OTHER_INDEX)
674 meta.gender = GENDER_OTHER_VALUE;
675 }
676
677 item.Get(m_MatchConfig.m_sCommentField, meta.comment);
678 item.Get(m_MatchConfig.m_sMaxLengthField, meta.maxLength);
679
680 if (!meta.IsEmpty())
681 result.m_Query.meta = meta;
682
683 return result;
684 }
685
686 //------------------------------------------------------------------------------------------------
689 protected map<string, string> GetAllItemTranslations(notnull BaseContainer item)
690 {
691 string prefix = m_MatchConfig.m_sLocaleFieldsPrefix;
692 string suffix = m_MatchConfig.m_sLocaleFieldsSuffix;
693
694 int prefixLength = prefix.Length();
695 int suffixLength = suffix.Length();
697
698 for (int i, count = item.GetNumVars(); i < count; ++i)
699 {
700 string varName = item.GetVarName(i);
701 string translation;
702 if (!item.Get(varName, translation))
703 continue;
704
705 // direct varName match - the source field may be formatted different than normal locale fields
706 // e.g m_sSource vs m_sTarget_it_it
707 if (varName == m_MatchConfig.m_sDefaultSource)
708 {
709 result.Insert(SOURCE_ID, translation);
710 continue;
711 }
712 else
713 if (varName == m_MatchConfig.m_sDefaultSourceEdited)
714 {
715 result.Insert(SOURCE_EDITED_ID, translation);
716 continue;
717 }
718
719 string locale;
720 if (prefixLength > 0 && varName != prefix && varName.StartsWith(prefix))
721 locale = varName.Substring(prefixLength, varName.Length() - prefixLength);
722
723 if (suffixLength > 0 && varName != suffix && varName.EndsWith(prefix))
724 locale = varName.Substring(0, varName.Length() - suffixLength);
725
726 if (locale.IsEmpty())
727 continue;
728
729 foreach (TranslationPluginConfigLocaleMatch localeMatch : m_MatchConfig.m_aLocalesDictionary)
730 {
731 if (localeMatch.m_sFileLocale == locale)
732 {
733 locale = localeMatch.m_sServerLocale;
734 break;
735 }
736 }
737
738 result.Insert(locale, translation);
739 }
740
741 return result;
742 }
743
744 //------------------------------------------------------------------------------------------------
749 protected int ApplyTranslations(notnull LocalizationEditor stringEditor, notnull TranslationPluginResponse response)
750 {
751 int modifiedRows;
752 bool hasBegunModifying;
753
754 string prefix = m_MatchConfig.m_sLocaleFieldsPrefix;
755 string suffix = m_MatchConfig.m_sLocaleFieldsSuffix;
756
757 foreach (TranslationPluginResult result : response.results)
758 {
759 // we use the idWrapperMap to only pick what was requested (do not trust the server blindly)
760 TranslationPlugin_DataWrapper wrapper = m_mIdDataMap.Get(result.id);
761 if (!wrapper)
762 {
763 if (m_bLogNetwork)
764 PrintFormat("Did NOT ask for %1, received it anyway", result.id, level: LogLevel.NORMAL);
765
766 continue;
767 }
768
769 array<string> languagesInResponse = {};
770
771 int modifiedLanguages;
772 array<string> localesToUse;
773 if (m_aGlobalLocales)
774 localesToUse = m_aGlobalLocales;
775 else
776 localesToUse = wrapper.m_Query.locales;
777
778 foreach (string locale : localesToUse)
779 {
780 string translation = GetTranslationFromLocale(locale, result.translations);
781 int length = translation.Length();
782
783 if (length < 1)
784 {
785 if (m_bLogNetwork)
786 PrintFormat("Asked for %1's %2 translation, did not obtain it", wrapper.m_Query.id, locale, level: LogLevel.WARNING);
787
788 continue;
789 }
790
791 if (wrapper.m_Query.meta && wrapper.m_Query.meta.maxLength > 0 && length > wrapper.m_Query.meta.maxLength)
792 {
793 if (m_bLogChanges)
794 PrintFormat("%1's %2 translation is too long: %3 chars > max %4 chars", result.id, locale, length, wrapper.m_Query.meta.maxLength, level: LogLevel.WARNING);
795 }
796
797 if (!hasBegunModifying)
798 {
799 stringEditor.BeginModify(PLUGIN_NAME);
800 hasBegunModifying = true;
801 }
802
803 stringEditor.ModifyProperty(wrapper.m_Item, wrapper.m_Item.GetVarIndex(prefix + locale + suffix), translation);
804 if (m_bLogChanges)
805 PrintFormat("Updating %1's %2 locale", result.id, locale, level: LogLevel.NORMAL);
806
807 ++modifiedLanguages;
808 }
809
810 if (m_bSetEditedAsOriginal && wrapper.m_bIsUpdate && !m_MatchConfig.m_sDefaultSourceEdited.IsEmpty())
811 {
812 if (!hasBegunModifying)
813 {
814 stringEditor.BeginModify(PLUGIN_NAME);
815 hasBegunModifying = true;
816 }
817
818 string updatedTranslation;
819 if (wrapper.m_Item.Get(m_MatchConfig.m_sDefaultSourceEdited, updatedTranslation) && !updatedTranslation.IsEmpty())
820 {
821 stringEditor.ModifyProperty(wrapper.m_Item, wrapper.m_Item.GetVarIndex(m_MatchConfig.m_sDefaultSource), updatedTranslation);
822 stringEditor.ModifyProperty(wrapper.m_Item, wrapper.m_Item.GetVarIndex(m_MatchConfig.m_sDefaultSourceEdited), "");
823
824 if (m_bLogChanges)
826 "Updating %1's %2 locale to %3 locale's value",
827 result.id,
828 m_MatchConfig.m_sDefaultSource,
829 m_MatchConfig.m_sDefaultSourceEdited,
830 level: LogLevel.NORMAL);
831
832 ++modifiedLanguages;
833 }
834 }
835
836 if (modifiedLanguages > 0)
837 ++modifiedRows;
838 }
839
840 if (hasBegunModifying)
841 stringEditor.EndModify();
842
843 return modifiedRows;
844 }
845
846 //------------------------------------------------------------------------------------------------
851 protected static string GetTranslationFromLocale(string locale, notnull TranslationPluginResultHolder holder)
852 {
853 Resource resource = BaseContainerTools.CreateContainerFromInstance(holder);
854 if (resource && resource.IsValid())
855 {
856 BaseContainer baseContainer = resource.GetResource().ToBaseContainer();
857 if (baseContainer)
858 {
859 string result;
860 if (baseContainer.Get(locale, result))
861 return result;
862 }
863 }
864
865 return string.Empty;
866 }
867
868 //------------------------------------------------------------------------------------------------
870 protected void ResetRequestState()
871 {
872 m_mIdDataMap = null;
873 m_iTranslationQueriesCount = 0;
874 m_aGlobalLocales = null;
875
876 m_bWaitingOnRestAPI = false;
877 m_iLastUsage = 0;
878 m_RestCallback = null;
879 m_ProgressBar = null;
880 }
881
882 //------------------------------------------------------------------------------------------------
885 protected TranslationPluginResponse GetFakeResponse()
886 {
887 TranslationPluginResponse response = new TranslationPluginResponse();
888 response.results = {};
889
890 foreach (string id, TranslationPlugin_DataWrapper wrapper : m_mIdDataMap)
891 {
892 TranslationPluginResultHolder translations = new TranslationPluginResultHolder();
893 Resource resource = BaseContainerTools.CreateContainerFromInstance(translations);
894 if (!resource || !resource.IsValid())
895 continue;
896
897 BaseContainer container = resource.GetResource().ToBaseContainer();
898 if (!container)
899 continue;
900
901 for (int i, count = container.GetNumVars(); i < count; ++i)
902 {
903 string varName = container.GetVarName(i);
904 string varNameUpper = varName;
905 varNameUpper.ToUpper();
906 container.Set(varName, varNameUpper + " translation");
907 }
908
909 BaseContainerTools.WriteToInstance(translations, container);
910
911 TranslationPluginResult result = new TranslationPluginResult();
912 result.id = id;
913 result.translations = translations;
914
915 response.results.Insert(result);
916 }
917
918 return response;
919 }
920
921 //------------------------------------------------------------------------------------------------
940 protected static bool SplitURL(string url, out string protocol, out string address, out string query)
941 {
942 url.TrimInPlace();
943 if (url.IsEmpty())
944 return false;
945
946 int protocolSeparatorIndex = url.IndexOf(PROTOCOL_SEPARATOR);
947 if (protocolSeparatorIndex < 0)
948 {
949 url = DEFAULT_PROTOCOL + PROTOCOL_SEPARATOR + url;
950 protocolSeparatorIndex = 5;
951 }
952 else
953 if (url.StartsWith(PROTOCOL_SEPARATOR))
954 {
955 url = DEFAULT_PROTOCOL + url;
956 protocolSeparatorIndex = 5;
957 }
958
959 bool isProtocolValid;
960 foreach (string acceptedProtocol : ACCEPTED_PROTOCOLS)
961 {
962 if (url.StartsWith(acceptedProtocol))
963 {
964 isProtocolValid = true;
965 break;
966 }
967 }
968
969 if (!isProtocolValid)
970 return false;
971
972 int addressIndex = protocolSeparatorIndex + PROTOCOL_SEPARATOR.Length();
973 int urlLength = url.Length();
974
975 if (urlLength == addressIndex)
976 return false;
977
978 int qIndex = url.IndexOfFrom(addressIndex, "?");
979 if (qIndex > -1 && qIndex < addressIndex)
980 return false;
981
982 protocol = url.Substring(0, addressIndex);
983
984 if (qIndex < 0)
985 {
986 address = url.Substring(addressIndex, urlLength - addressIndex);
987 query = "";
988 }
989 else
990 {
991 address = url.Substring(addressIndex, qIndex - addressIndex);
992 if (qIndex + 1 == urlLength) // ending with "?"
993 query = "";
994 else
995 query = url.Substring(qIndex + 1, urlLength - qIndex - 1);
996 }
997
998 return true;
999 }
1000
1001 //------------------------------------------------------------------------------------------------
1005 static string SanitizeToken(string token)
1006 {
1007 string result;
1008 for (int i, length = token.Length(); i < length; ++i)
1009 {
1010 if (TOKEN_WHITELIST.Contains(token[i]))
1011 result += token[i];
1012 }
1013
1014 return result;
1015 }
1016
1017 //------------------------------------------------------------------------------------------------
1021 protected static bool AreLocalesEqual(notnull array<string> localesA, notnull array<string> localesB)
1022 {
1023 if (localesA.Count() != localesB.Count())
1024 return false;
1025
1026 foreach (int i, string localeA : localesA)
1027 {
1028 if (localeA != localesB[i])
1029 return false;
1030 }
1031
1032 return true;
1033 }
1034
1035 //------------------------------------------------------------------------------------------------
1037 protected void ResetConfig()
1038 {
1039 typename configTypeName = TranslationPluginMatchConfig;
1040 Resource resource = BaseContainerTools.CreateContainer(configTypeName.ToString());
1041 BaseContainer baseContainer = resource.GetResource().ToBaseContainer();
1042 Managed managed = BaseContainerTools.CreateInstanceFromContainer(baseContainer);
1043 m_MatchConfig = TranslationPluginMatchConfig.Cast(managed);
1044 }
1045
1046 //------------------------------------------------------------------------------------------------
1049 protected bool LoadConfig(ResourceName resourceName)
1050 {
1051 if (resourceName.IsEmpty())
1052 {
1053 PrintDialog("Please provide the Config File Path field in order to load something.", PLUGIN_NAME, LogLevel.WARNING);
1054 return false;
1055 }
1056
1057 Resource resource = Resource.Load(resourceName);
1058 if (!resource.IsValid())
1059 {
1060 PrintDialog("The provided config is not a valid resource.", PLUGIN_NAME, LogLevel.WARNING);
1061 return false;
1062 }
1063
1064 BaseContainer baseContainer = resource.GetResource().ToBaseContainer();
1065 if (!baseContainer)
1066 {
1067 PrintDialog("The provided config does not have a base container.", PLUGIN_NAME, LogLevel.WARNING);
1068 return false;
1069 }
1070
1071 Managed managed = BaseContainerTools.CreateInstanceFromContainer(baseContainer);
1072 if (!managed)
1073 {
1074 PrintDialog("The provided config cannot be instanciated.", PLUGIN_NAME, LogLevel.WARNING);
1075 return false;
1076 }
1077
1078 TranslationPluginMatchConfig result = TranslationPluginMatchConfig.Cast(managed);
1079 if (!result)
1080 {
1081 PrintDialog("The provided config is not a valid TranslationPluginMatchConfig.", PLUGIN_NAME, LogLevel.WARNING);
1082 return false;
1083 }
1084
1085 m_MatchConfig = result;
1086
1087 // do not PrintDialog loading confirmation
1088 return true;
1089 }
1090
1091 //------------------------------------------------------------------------------------------------
1092 protected override void Configure()
1093 {
1094 if (Workbench.ScriptDialog(
1095 PLUGIN_NAME,
1096 "Set the file parsing and communication options here."
1097 + "\nMandatory fields are Server URL, Match Config, and in Match Config: Id and Default Source.",
1098 this) == 1)
1099 Configure(); // if Configure* methods return false, reopen the Configuration panel
1100 }
1101
1102 //------------------------------------------------------------------------------------------------
1104 protected bool ConfigureClose()
1105 {
1106 if (!m_MatchConfig) // quit deleting it, dangit
1107 ResetConfig();
1108
1109 string sanitisedToken = SanitizeToken(m_sServerToken);
1110 if (m_sServerToken != sanitisedToken)
1111 {
1112 PrintDialog("The token you provided was sanitised.", PLUGIN_NAME, LogLevel.WARNING);
1113 m_sServerToken = sanitisedToken;
1114 }
1115
1116 return true;
1117 }
1118
1119 //------------------------------------------------------------------------------------------------
1121 protected bool ConfigureValidate()
1122 {
1123 array<string> errors = {};
1124
1125 m_sServerURL.TrimInPlace();
1126 if (m_sServerURL.IsEmpty())
1127 errors.Insert("the Server URL field is empty");
1128
1129 if (m_sServerToken != SanitizeToken(m_sServerToken))
1130 errors.Insert("the Server Token value is invalid; be sure to remove any invalid characters (allowed: a-zA-Z0-9-._~+/=)");
1131
1132 if (m_MatchConfig)
1133 {
1134 m_MatchConfig.m_sIdField.TrimInPlace();
1135 if (m_MatchConfig.m_sIdField.IsEmpty())
1136 errors.Insert("the Id field is empty");
1137
1138 m_MatchConfig.m_sDefaultSource.TrimInPlace();
1139 if (m_MatchConfig.m_sDefaultSource.IsEmpty())
1140 errors.Insert("the Default Source field is empty");
1141 }
1142 else
1143 {
1144 errors.Insert("the Match Config field is null");
1145 ResetConfig();
1146 }
1147
1148 if (!errors.IsEmpty())
1149 {
1150 string message = "Mandatory fields are missing:";
1151 foreach (string error : errors)
1152 {
1153 message += "\n- " + error;
1154 }
1155
1156 PrintDialog(message, PLUGIN_NAME + " configuration", LogLevel.WARNING);
1157 return false;
1158 }
1159
1160 PrintDialog("Configuration seems correct.", PLUGIN_NAME + " configuration", LogLevel.NORMAL);
1161 return true;
1162 }
1163
1164 //------------------------------------------------------------------------------------------------
1166 protected bool ConfigureLoadConfig()
1167 {
1168 TranslationPlugin_LoadConfigUI loadConfigUI = new TranslationPlugin_LoadConfigUI();
1169 while (true) // ugh
1170 {
1171 if (Workbench.ScriptDialog(PLUGIN_NAME, "Load a string table match config", loadConfigUI) == 0) // Cancel
1172 break;
1173
1174 if (loadConfigUI.m_sConfigFilePath.IsEmpty())
1175 PrintDialog("Please provide a Config file path (or press Cancel to abort)", PLUGIN_NAME, LogLevel.WARNING);
1176 else
1177 if (LoadConfig(loadConfigUI.m_sConfigFilePath)) // LoadConfig has its own PrintDialog
1178 break;
1179 }
1180
1181 return true;
1182 }
1183
1184 //------------------------------------------------------------------------------------------------
1185 [ButtonAttribute("Load Config")]
1186 protected int ButtonLoadConfig()
1187 {
1188 ConfigureLoadConfig();
1189 return 1;
1190 }
1191
1192 //------------------------------------------------------------------------------------------------
1193 [ButtonAttribute("Validate/Close")]
1194 protected int ButtonValidateClose()
1195 {
1196 if (ConfigureValidate())
1197 return 0;
1198 else
1199 return 1;
1200 }
1201
1202 //------------------------------------------------------------------------------------------------
1203 [ButtonAttribute("Close", true)]
1204 protected int ButtonClose()
1205 {
1206 ConfigureClose();
1207 return 0;
1208 }
1209
1210 //
1211 // helpful methods
1212 //
1213
1214 //------------------------------------------------------------------------------------------------
1219 protected static void PrintDialog(string message, string caption, LogLevel level)
1220 {
1221 PrintFormat("[%1] %2", caption, message, level: LogLevel.NORMAL);
1222 Workbench.Dialog(caption, message);
1223 }
1224
1225 //------------------------------------------------------------------------------------------------
1233 protected static void PrintFormatDialog(string message, string param1, string param2 = "", string param3 = "", string caption = "", LogLevel level = LogLevel.WARNING)
1234 {
1235 message = string.Format(message, param1, param2, param3);
1236 PrintFormat("[%1] %2", caption, message, level: level);
1237 Workbench.Dialog(caption, message);
1238 }
1239
1240 //------------------------------------------------------------------------------------------------
1244 protected static string FormatDurationMs(int milliSeconds)
1245 {
1246 if (milliSeconds == 0)
1247 return "no time";
1248
1249 if (milliSeconds < 0)
1250 milliSeconds = -milliSeconds;
1251
1252 if (milliSeconds < 60000)
1253 return string.Format("%1s", (milliSeconds * 0.001).ToString(lenDec: 1));
1254
1255 int totalSeconds = Math.Round(milliSeconds * 0.001);
1256
1257 int hours = totalSeconds / 3600;
1258 int minutes = (totalSeconds - hours * 3600) / 60;
1259 int seconds = (totalSeconds - hours * 3600 - minutes * 60);
1260
1261 if (hours < 1)
1262 return string.Format("%1:%2", minutes.ToString(2), seconds.ToString(2));
1263
1264 return string.Format("%1:%2:%3", hours, minutes.ToString(2), seconds.ToString(2));
1265 }
1266
1267 //------------------------------------------------------------------------------------------------
1268 override void OnStringTableItemContextMenu()
1269 {
1270 Run();
1271 }
1272}
1273
1274class TranslationPlugin_LoadConfigUI
1275{
1276 [Attribute(
1277 defvalue: DEFAULT_MATCH_CONFIG,
1278 desc: "Config to load into the Match Config field using the Load Config button - overrides any existing config",
1279 category: "Advanced",
1280 params: "conf class=TranslationPluginMatchConfig")]
1281 ResourceName m_sConfigFilePath;
1282
1283 protected static const ResourceName DEFAULT_MATCH_CONFIG = "{9414DBD68A4429DB}Configs/Workbench/LocalizationEditor/TranslatePlugin/BaseStringTableItemConfig.conf";
1284
1285 //------------------------------------------------------------------------------------------------
1286 [ButtonAttribute("Load", true)]
1287 protected int ButtonLoad()
1288 {
1289 return 1;
1290 }
1291
1292 //------------------------------------------------------------------------------------------------
1293 [ButtonAttribute("Cancel")]
1294 protected int ButtonCancel()
1295 {
1296 return 0;
1297 }
1298
1299 void TranslationPlugin_LoadConfigUI()
1300 {
1301 if (m_sConfigFilePath.IsEmpty())
1302 m_sConfigFilePath = DEFAULT_MATCH_CONFIG;
1303 }
1304}
1305
1306class TranslationPlugin_DataWrapper
1307{
1308 BaseContainer m_Item;
1309 bool m_bIsUpdate;
1310
1311 ref TranslationPluginQuery m_Query;
1312}
1313
1314enum ETranslationPlugin_EditedRowsMode
1315{
1316 PROCESS_ALL,
1317 PROCESS_UNEDITED_ONLY,
1318 PROCESS_EDITED_ONLY,
1319}
1320
1321enum ETranslationPlugin_ProcessMode
1322{
1323 PRODUCTION,
1324 SIMULATE_SUCCESS,
1325 SIMULATE_ERROR_403,
1326 SIMULATE_ERROR_404,
1327 SIMULATE_ERROR_408,
1328 SIMULATE_ERROR_418,
1329 SIMULATE_ERROR_500,
1330 SIMULATE_ERROR_501,
1331}
1332#endif
AddonBuildInfoTool id
ref DSGameConfig game
Definition DSConfig.c:81
string error
GenerateFlowMaps WorkbenchPlugin WorkbenchPluginAttribute("Regenerate river flow-maps", "Generate and save/overwrite river flow-maps", "", "", {"WorldEditor"}, "", 0xf773)
Definition FlowmapTool.c:59
ArmaReforgerScripted GetGame()
Definition game.c:1398
ResourceName resourceName
Definition SCR_AIGroup.c:66
override void Run()
bool ButtonCancel()
NewsFeedItem m_Item
UI Textures DeployMenu Briefing conflict_HintBanner_1_UI desc
override void Configure()
class WorkbenchDialog_AbortRetryIgnore ButtonAttribute("OK", true)
Definition Game.c:8
Definition Math.c:13
Object holding reference to resource. In destructor release the resource.
Definition Resource.c:25
Script accessible REST context.
Definition RestContext.c:14
Base class for scripted string table item.
Definition Types.c:486
proto void Print(void var, LogLevel level=LogLevel.NORMAL)
Prints content of variable to console/log.
LogLevel
Enum with severity of the logging message.
Definition LogLevel.c:14
proto void PrintFormat(string fmt, void param1=NULL, void param2=NULL, void param3=NULL, void param4=NULL, void param5=NULL, void param6=NULL, void param7=NULL, void param8=NULL, void param9=NULL, LogLevel level=LogLevel.NORMAL)
SCR_FieldOfViewSettings Attribute
T3 param3
Definition tuple.c:93
T2 param2
Definition tuple.c:92
Tuple param1
proto external string ToString()
Plain C++ pointer, no weak pointers, no memory management.
proto native bool IsEmpty()
ERestResult
States and result + error code produced by RestApi.
Definition ERestResult.c:14
HttpCode
Definition HttpCode.c:19