using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; using Crowdin.Plugin.Helpers.FileHelpers; using Crowdin.Plugin.Helpers.PluginLocalization; using Crowdin.Plugin.Models; using Crowdin.Plugin.Models.Settings; using Crowdin.Plugin.Operations; using Crowdin.Plugin.Server_API; using Crowdin.Plugin.Server_API.Helpers; using Crowdin.Plugin.Server_API.Models; using Crowdin.Plugin.Server_API.Models.Branches; using Crowdin.Plugin.Server_API.Models.Labels; using Crowdin.Plugin.Server_API.Models.SourceStrings; using Crowdin.Plugin.Server_API.Services; #nullable enable namespace Crowdin.Plugin.Helpers.UnityLocalization { /// Label lookup and the string-based-project upload/poll flow that both PushStrings and /// PushStringTranslations share when the project is string-based (CSV-only, no source files). public static class StringBasedProjectsHelper { private static readonly LabelsApiService LabelsApi = new LabelsApiService(); public static async Task FindLabelByTitle(string labelTitle, CancellationToken cancellationToken) { List labels = await LabelsApi.ListLabels(cancellationToken); return labels.FirstOrDefault(label => label.Title.Equals(labelTitle)); } /// Returns whether the upload completed successfully; the caller decides how a failed /// table affects its own counts and whether to continue with the remaining tables. public static async Task UploadStringBasedProject( UploadProjectCommand command, IProgress progress, CancellationToken cancellationToken) { var branchService = new BranchApiService(); var storageService = new StorageApiService(); var sourceStringService = new SourceStringsApiService(); try { int branchId; if (command.SelectedBranchId.HasValue) { branchId = command.SelectedBranchId.Value; } else { const string TargetBranch = "main"; List branches = await branchService.ListBranches(cancellationToken); BranchData? mainBranch = branches.FirstOrDefault(branch => branch.Name is TargetBranch); if (mainBranch is null) { progress.Report(new OperationProgress( LocalizationHelper.GetLocalStringByKey(LocalKeys.branch_not_found, TargetBranch))); return false; } branchId = mainBranch.Id; } var tableFileName = $"{command.TableName}.csv"; var tableFullFilePath = $"{FolderHelper.resourcesDirectory}/{tableFileName}"; CreateFileHelper.SaveTranslationsToFile(tableFullFilePath, command.TableName, command.IncludeTranslations); StorageData storageData; try { storageData = await storageService.CreateStorage(tableFileName, tableFullFilePath, cancellationToken); } finally { RemoveFileHelper.RemoveFile(tableFullFilePath); } OperationResult uploadResult = await sourceStringService.UploadStrings( new UploadStringsRequest { StorageId = storageData.id, BranchId = branchId, Type = "csv", ImportOptions = new SpreadsheetFileImportOptions { FirstLineContainsHeader = true, ImportTranslations = command.IncludeTranslations, Scheme = StorageHelper.CreateImportOptionsScheme() }, UpdateStrings = command.UpdateStrings, CleanupMode = command.CleanupMode, UpdateOption = command.UpdateOption }, cancellationToken); if (CheckErrorAndPrint(uploadResult, progress)) return false; if (uploadResult.Result is null) { progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey( LocalKeys.error_api, "Empty upload response from Crowdin."))); return false; } PrintProgress(uploadResult.Result, progress); const int maxPollAttempts = 300; // 1 request/second; bounds the poll instead of looping forever var pollAttempt = 0; // Ensure finished while (uploadResult.Result.Status is not "finished" and not "failed" and not "canceled") { if (++pollAttempt > maxPollAttempts) { progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey( LocalKeys.error_api, "Upload status polling timed out."))); return false; } // Wait between check requests await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); uploadResult = await sourceStringService.CheckUploadStringsStatus(uploadResult.Result.Identifier, cancellationToken); if (CheckErrorAndPrint(uploadResult, progress)) return false; if (uploadResult.Result is null) { progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey( LocalKeys.error_api, "Empty upload response from Crowdin."))); return false; } PrintProgress(uploadResult.Result, progress); } if (uploadResult.Result.Status is not "finished") { progress.Report(new OperationProgress( LocalizationHelper.GetLocalStringByKey(LocalKeys.error_api, uploadResult.Result.Status))); return false; } progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey(LocalKeys.upload_finished))); return true; } catch (CrowdinApiException ex) { // Any API failure here (branch lookup, storage creation, the upload itself) must fail // just this one table, not the whole batch - same contract as every other failure path // in this method already returns (see CheckErrorAndPrint above). Debug.LogException(ex); progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByException(ex))); return false; } } public sealed class UploadProjectCommand { public string TableName { get; set; } = string.Empty; public int? SelectedBranchId { get; set; } public bool? UpdateStrings { get; set; } public bool? CleanupMode { get; set; } public string? UpdateOption { get; set; } public bool IncludeTranslations { get; set; } } private static void PrintProgress(StringUploadResponseModel response, IProgress progress) { progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey( LocalKeys.progress_status, response.Status, response.Progress.ToString()))); } private static bool CheckErrorAndPrint(OperationResult result, IProgress progress) { if (result.IsSuccess) return false; progress.Report(new OperationProgress(LocalizationHelper.GetLocalStringByKey( LocalKeys.error_api, result.ErrorMessage))); return true; } } }