using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Google.Apis.Requests; using Google.Apis.Sheets.v4.Data; using UnityEditor.Localization.Plugins.Google.Columns; using UnityEditor.Localization.Reporting; using UnityEngine; using UnityEngine.Localization.Metadata; using static Google.Apis.Sheets.v4.SpreadsheetsResource; using static UnityEngine.Localization.Tables.SharedTableData; using Data = Google.Apis.Sheets.v4.Data; using Object = UnityEngine.Object; namespace UnityEditor.Localization.Plugins.Google { /// /// Provides an interface for syncing localization data to a Google Sheet. /// public class GoogleSheets { /// /// The sheets provider is responsible for providing the SheetsService and configuring the type of access. /// . /// public IGoogleSheetsService SheetsService { get; private set; } /// /// The Id of the Google Sheet. This can be found by examining the url: /// https://docs.google.com/spreadsheets/d/SpreadsheetId/edit#gid=SheetId /// Further information can be found here. /// public string SpreadSheetId { get; set; } /// /// Is an API key being used or is it an OAuth? /// internal protected virtual bool UsingApiKey => (SheetsService as SheetsServiceProvider)?.Authentication != AuthenticationType.OAuth; /// /// Creates a new instance of a GoogleSheets connection. /// /// The Google Sheets service provider. See for a default implementation. public GoogleSheets(IGoogleSheetsService provider) { if (provider == null) throw new ArgumentNullException(nameof(provider)); SheetsService = provider; } /// /// Opens the spreadsheet in a browser. /// /// public static void OpenSheetInBrowser(string spreadSheetId) => Application.OpenURL($"https://docs.google.com/spreadsheets/d/{spreadSheetId}/"); /// /// Opens the spreadsheet with the sheet selected in a browser. /// /// /// public static void OpenSheetInBrowser(string spreadSheetId, int sheetId) => Application.OpenURL($"https://docs.google.com/spreadsheets/d/{spreadSheetId}/#gid={sheetId}"); /// /// Creates a new Google Spreadsheet. /// /// The title of the Spreadsheet. /// The title of the sheet(tab) that is part of the Spreadsheet. /// /// Optional reporter to display the progress and status of the task. /// Returns the new Spreadsheet and sheet id. public (string spreadSheetId, int sheetId) CreateSpreadsheet(string spreadSheetTitle, string sheetTitle, NewSheetProperties newSheetProperties, ITaskReporter reporter = null) { if (newSheetProperties == null) throw new ArgumentNullException(nameof(newSheetProperties)); try { if (reporter != null && reporter.Started != true) reporter.Start("Create Spreadsheet", "Preparing Request"); var createRequest = SheetsService.Service.Spreadsheets.Create(new Spreadsheet { Properties = new SpreadsheetProperties { Title = spreadSheetTitle }, Sheets = new Sheet[] { new Sheet { Properties = new SheetProperties { Title = sheetTitle, } } } }); reporter?.ReportProgress("Sending create request", 0.2f); var createResponse = ExecuteRequest(createRequest); SpreadSheetId = createResponse.SpreadsheetId; var sheetId = createResponse.Sheets[0].Properties.SheetId.Value; reporter?.ReportProgress("Setting up new sheet", 0.5f); SetupSheet(SpreadSheetId, sheetId, newSheetProperties); reporter?.Completed(string.Empty); return (SpreadSheetId, sheetId); } catch (Exception e) { reporter?.Fail(e.Message); throw; } } /// /// Creates a new sheet within the Spreadsheet with the id . /// /// The title for the new sheet /// The settings to apply to the new sheet. /// The new sheet id. public int AddSheet(string title, NewSheetProperties newSheetProperties) { if (string.IsNullOrEmpty(SpreadSheetId)) throw new Exception($"{nameof(SpreadSheetId)} is required. Please assign a valid Spreadsheet Id to the property."); if (newSheetProperties == null) throw new ArgumentNullException(nameof(newSheetProperties)); var createRequest = new Request() { AddSheet = new AddSheetRequest { Properties = new SheetProperties { Title = title } } }; var batchUpdateReqTask = SendBatchUpdateRequest(SpreadSheetId, createRequest); var sheetId = batchUpdateReqTask.Replies[0].AddSheet.Properties.SheetId.Value; SetupSheet(SpreadSheetId, sheetId, newSheetProperties); return sheetId; } /// /// Returns a list of all the sheets in the Spreadsheet with the id . /// /// The sheets names and id's. public List<(string name, int id)> GetSheets() { if (string.IsNullOrEmpty(SpreadSheetId)) throw new Exception($"The {nameof(SpreadSheetId)} is required. Please assign a valid Spreadsheet Id to the property."); var sheets = new List<(string name, int id)>(); var spreadsheetInfoRequest = SheetsService.Service.Spreadsheets.Get(SpreadSheetId); var sheetInfoReq = ExecuteRequest(spreadsheetInfoRequest); foreach (var sheet in sheetInfoReq.Sheets) { sheets.Add((sheet.Properties.Title, sheet.Properties.SheetId.Value)); } return sheets; } /// /// Returns all the column titles(values from the first row) for the selected sheet inside of the Spreadsheet with id . /// This method requires the to use OAuth authorization as it uses a data filter which reuires elevated authorization. /// /// The sheet id. /// All the public IList GetColumnTitles(int sheetId) { if (string.IsNullOrEmpty(SpreadSheetId)) throw new Exception($"{nameof(SpreadSheetId)} is required."); var batchGetValuesByDataFilterRequest = new BatchGetValuesByDataFilterRequest { DataFilters = new DataFilter[1] { new DataFilter { GridRange = new GridRange { SheetId = sheetId, StartRowIndex = 0, EndRowIndex = 1 } } } }; var request = SheetsService.Service.Spreadsheets.Values.BatchGetByDataFilter(batchGetValuesByDataFilterRequest, SpreadSheetId); var result = ExecuteRequest(request); var titles = new List(); if (result?.ValueRanges?.Count > 0 && result.ValueRanges[0].ValueRange.Values != null) { foreach (var row in result.ValueRanges[0].ValueRange.Values) { foreach (var col in row) { titles.Add(col.ToString()); } } } return titles; } /// /// Asynchronous version of /// /// /// The sheet to get the row count from /// The row count for the sheet. public async Task GetRowCountAsync(int sheetId) { var rowCountRequest = GenerateGetRowCountRequest(sheetId); var task = ExecuteRequestAsync(rowCountRequest); await task.ConfigureAwait(true); if (task.Result.Sheets == null || task.Result.Sheets.Count == 0) throw new Exception($"No sheet data available for {sheetId} in Spreadsheet {SpreadSheetId}."); return task.Result.Sheets[0].Properties.GridProperties.RowCount.Value; } /// /// Returns the total number of rows in the sheet inside of the Spreadsheet with id . /// This method requires the to use OAuth authorization as it uses a data filter which reuires elevated authorization. /// /// The sheet to get the row count from. /// The row count for the sheet. public int GetRowCount(int sheetId) { var rowCountRequest = GenerateGetRowCountRequest(sheetId); var response = ExecuteRequest(rowCountRequest); if (response.Sheets == null || response.Sheets.Count == 0) throw new Exception($"No sheet data available for {sheetId} in Spreadsheet {SpreadSheetId}."); return response.Sheets[0].Properties.GridProperties.RowCount.Value; } GetByDataFilterRequest GenerateGetRowCountRequest(int sheetId) { if (string.IsNullOrEmpty(SpreadSheetId)) throw new Exception($"{nameof(SpreadSheetId)} is required."); return SheetsService.Service.Spreadsheets.GetByDataFilter(new GetSpreadsheetByDataFilterRequest { DataFilters = new DataFilter[] { new DataFilter { GridRange = new GridRange { SheetId = sheetId, }, }, }, }, SpreadSheetId); } /// /// Asynchronous version of /// /// /// The sheet(Spreadsheet tab) to insert the data into. /// The collection to extract the data from. /// The column mappings control what data will be extracted for each column of the sheet. The list must contain 1 . /// Optional reporter to display the progress and status of the task. /// public async Task PushStringTableCollectionAsync(int sheetId, StringTableCollection collection, IList columnMapping, ITaskReporter reporter = null) { VerifyPushPullArguments(sheetId, collection, columnMapping, typeof(KeyColumn)); // Nothing to push if (collection.StringTables.Count == 0) return; try { if (reporter != null && reporter.Started != true) reporter.Start($"Push `{collection.TableCollectionName}` to Google Sheets", "Checking if sheet needs resizing"); var requests = new List(); var rowCountTask = GetRowCountAsync(sheetId); await rowCountTask.ConfigureAwait(true); var rowCount = rowCountTask.Result; // Do we need to resize the sheet? var requiredRows = collection.SharedData.Entries.Count + 1; // + 1 for the header row if (collection.SharedData.Entries.Count > rowCount) { reporter?.ReportProgress("Generating sheet resize request", 0.15f); requests.Add(ResizeRow(sheetId, requiredRows)); } GeneratePushRequests(sheetId, collection, columnMapping, requests, reporter); reporter?.ReportProgress("Sending Request", 0.5f); var sendTask = SendBatchUpdateRequestAsync(SpreadSheetId, requests); await sendTask.ConfigureAwait(true); reporter?.Completed($"Pushed {requiredRows} rows and {requiredRows * columnMapping.Count} cells successfully."); } catch (Exception e) { reporter?.Fail(e.ToString()); throw; } } /// /// Extracts data from using and sends it to the sheet /// inside of the Spreadsheet with id . /// This method requires the to use OAuth authorization as an API Key does not have the ability to write to a sheet. /// /// The sheet(Spreadsheet tab) to insert the data into. /// The collection to extract the data from. /// The column mappings control what data will be extracted for each column of the sheet. The list must contain 1 . /// Optional reporter to display the progress and status of the task. /// /// A can exist over several Google Sheets, for example one per Locale. /// This example shows to push one of those Locales. /// /// /// /// This example shows how to push all the locales in your project by using to generate the column mapping data for you. /// /// /// /// This example shows how to use the data that was configured in the to perform a push. /// /// /// /// This example shows how to push every that contains a . /// /// public void PushStringTableCollection(int sheetId, StringTableCollection collection, IList columnMapping, ITaskReporter reporter = null) { VerifyPushPullArguments(sheetId, collection, columnMapping, typeof(KeyColumn)); // Nothing to push if (collection.StringTables.Count == 0) return; try { if (reporter != null && reporter.Started != true) reporter.Start($"Push `{collection.TableCollectionName}` to Google Sheets", "Checking if sheet needs resizing"); var requests = new List(); var rowCount = GetRowCount(sheetId); // Do we need to resize the sheet? var requiredRows = collection.SharedData.Entries.Count + 1; // + 1 for the header row if (collection.SharedData.Entries.Count > rowCount) { reporter?.ReportProgress("Generating sheet resize request", 0.15f); requests.Add(ResizeRow(sheetId, requiredRows)); } GeneratePushRequests(sheetId, collection, columnMapping, requests, reporter); reporter?.ReportProgress("Sending Request", 0.5f); var resp = SendBatchUpdateRequest(SpreadSheetId, requests); reporter?.Completed($"Pushed {requiredRows} rows successfully."); } catch (Exception e) { reporter?.Fail(e.ToString()); throw; } } void GeneratePushRequests(int sheetId, StringTableCollection collection, IList columnMapping, List requestsToSend, ITaskReporter reporter) { // Prepare the column requests. // We use a request per column as its possible that some columns in the sheet will be preserved and we don't want to write over them. reporter?.ReportProgress("Generating column headers", 0); var columnSheetRequests = new List(columnMapping.Count); foreach (var col in columnMapping) { var colRequest = new PushColumnSheetRequest(sheetId, col); columnSheetRequests.Add(colRequest); colRequest.Column.PushBegin(collection); colRequest.Column.PushHeader(collection, out var header, out var note); colRequest.AddHeader(header, note); } reporter?.ReportProgress("Generating push data", 0.1f); foreach (var row in collection.GetRowEnumeratorUnsorted()) { foreach (var colReq in columnSheetRequests) { if (row.KeyEntry.Metadata.HasMetadata()) continue; colReq.Column.PushCellData(row.KeyEntry, row.TableEntries, out var value, out var note); colReq.AddRow(value, note); } } foreach (var col in columnSheetRequests) { col.Column.PushEnd(); requestsToSend.AddRange(col.Requests); } } /// /// Pulls data from the Spreadsheet with id and uses /// to populate the . /// /// The sheet(Spreadsheet tab) to pull the data from. /// The collection to insert the data into. /// The column mappings control what data to extract for each column of the sheet. The list must contain one . /// After a pull has completed, any keys that exist in the but did not exist in the sheet are considered missing, /// this may be because they have been deleted from the sheet. A value of true will remove these missing entries; false will preserve them. /// Optional reporter to display the progress and status of the task. /// Should an Undo be recorded so any changes can be reverted? /// /// A can exist over several Google Sheets, for example one per Locale. /// This example shows how to pull one of those Locales into a . /// /// /// /// This example shows how to pull all the locales in your project by using the to generate the column mapping data for you. /// /// /// /// This example shows how to use the data that was configured in a Google Sheets extension to perform a pull. /// /// /// /// This example shows how to pull every that contains a . /// /// public void PullIntoStringTableCollection(int sheetId, StringTableCollection collection, IList columnMapping, bool removeMissingEntries = false, ITaskReporter reporter = null, bool createUndo = false) { VerifyPushPullArguments(sheetId, collection, columnMapping, typeof(IPullKeyColumn)); try { var modifiedAssets = collection.StringTables.Select(t => t as Object).ToList(); modifiedAssets.Add(collection.SharedData); if (createUndo) { Undo.RegisterCompleteObjectUndo(modifiedAssets.ToArray(), $"Pull `{collection.TableCollectionName}` from Google sheets"); } if (reporter != null && reporter.Started != true) reporter.Start($"Pull `{collection.TableCollectionName}` from Google sheets", "Preparing columns"); // The response columns will be in the same order we request them, we need the key // before we can process any values so ensure the first column is the key column. var sortedColumns = columnMapping.OrderByDescending(c => c is IPullKeyColumn).ToList(); // We can only use public API. No data filters. // We use a data filter when possible as it allows us to remove a lot of unnecessary information, // such as unneeded sheets and columns, which reduces the size of the response. A Data filter can only be used with OAuth authentication. reporter?.ReportProgress("Generating request", 0.1f); ClientServiceRequest pullReq = UsingApiKey ? GeneratePullRequest() : GenerateFilteredPullRequest(sheetId, columnMapping); reporter?.ReportProgress("Sending request", 0.2f); var response = ExecuteRequest>(pullReq); reporter?.ReportProgress("Validating response", 0.5f); // When using an API key we get all the sheets so we need to extract the one we are pulling from. var sheet = UsingApiKey ? response.Sheets?.FirstOrDefault(s => s?.Properties?.SheetId == sheetId) : response.Sheets[0]; if (sheet == null) throw new Exception($"No sheet data available for {sheetId} in Spreadsheet {SpreadSheetId}."); // The data will be structured differently if we used a filter or not so we need to extract the parts we need. var pulledColumns = new List<(IList rowData, int valueIndex)>(); if (UsingApiKey) { // When getting the whole sheet all the columns are stored in a single Data. We need to extract the correct value index for each column. foreach (var sortedCol in sortedColumns) { pulledColumns.Add((sheet.Data[0].RowData, sortedCol.ColumnIndex)); } } else { if (sheet.Data.Count != columnMapping.Count) throw new Exception($"Column mismatch. Expected a response with {columnMapping.Count} columns but only got {sheet.Data.Count}"); // When using a filter each Data represents a single column. foreach (var d in sheet.Data) { pulledColumns.Add((d.RowData, 0)); } } MergePull(pulledColumns, collection, columnMapping, UsingApiKey, removeMissingEntries, reporter); // There is a bug that causes Undo to not set assets dirty (case 1240528) so we always set the asset dirty. modifiedAssets.ForEach(EditorUtility.SetDirty); LocalizationEditorSettings.EditorEvents.RaiseCollectionModified(this, collection); // Flush changes to disk. collection.SaveChangesToDisk(); } catch (Exception e) { reporter?.Fail(e.Message); throw; } } void VerifyPushPullArguments(int sheetId, StringTableCollection collection, IList columnMapping, Type requiredKeyType) { if (string.IsNullOrEmpty(SpreadSheetId)) throw new Exception($"{nameof(SpreadSheetId)} is required."); if (collection == null) throw new ArgumentNullException(nameof(collection)); if (columnMapping == null) throw new ArgumentNullException(nameof(columnMapping)); if (columnMapping.Count == 0) throw new ArgumentException("Must include at least 1 column.", nameof(columnMapping)); if (columnMapping.Count(c => requiredKeyType.IsAssignableFrom(c.GetType())) != 1) throw new ArgumentException($"Must include 1 {requiredKeyType.Name}.", nameof(columnMapping)); ThrowIfDuplicateColumnIds(columnMapping); } ClientServiceRequest GeneratePullRequest() { var request = SheetsService.Service.Spreadsheets.Get(SpreadSheetId); request.IncludeGridData = true; request.Fields = "sheets.properties.sheetId,sheets.properties.gridProperties.rowCount,sheets.data.rowData.values.formattedValue,sheets.data.rowData.values.note"; return request; } ClientServiceRequest GenerateFilteredPullRequest(int sheetId, IList columnMapping) { var getRequest = new GetSpreadsheetByDataFilterRequest { DataFilters = new List() }; foreach (var col in columnMapping) { getRequest.DataFilters.Add(new DataFilter { GridRange = new GridRange { SheetId = sheetId, StartRowIndex = 1, // Ignore header StartColumnIndex = col.ColumnIndex, EndColumnIndex = col.ColumnIndex + 1 } }); } var request = SheetsService.Service.Spreadsheets.GetByDataFilter(getRequest, SpreadSheetId); request.Fields = "sheets.properties.gridProperties.rowCount,sheets.data.rowData.values.formattedValue,sheets.data.rowData.values.note"; return request; } void MergePull(List<(IList rowData, int valueIndex)> columns, StringTableCollection collection, IList columnMapping, bool skipFirstRow, bool removeMissingEntries, ITaskReporter reporter) { reporter?.ReportProgress("Preparing to merge", 0.55f); // Keep track of any issues for a single report instead of filling the console. var messages = new StringBuilder(); var keyColumn = columnMapping[0] as IPullKeyColumn; Debug.Assert(keyColumn != null, "Expected the first column to be a Key column"); var rowCount = columns[0].rowData != null ? columns[0].rowData.Count : 0; // Send the start message foreach (var col in columnMapping) { col.PullBegin(collection); } reporter?.ReportProgress("Merging response into collection", 0.6f); var keysProcessed = new HashSet(); // We want to keep track of the order the entries are pulled in so we can match it var sortedEntries = new List(rowCount); var addedIds = new Dictionary(); // So we dont add duplicates. (id,row) var addedKeys = new Dictionary(); // So we dont add duplicate names. (name, row) long totalCellsProcessed = 0; var keyValueIndex = columns[0].valueIndex; for (int row = skipFirstRow ? 1 : 0; row < rowCount; row++) { var keyRowData = columns[0].rowData[row]; var keyData = keyRowData?.Values ? [keyValueIndex]; var keyValue = keyData?.FormattedValue; var keyNote = keyData?.Note; // Skip rows with no key data if (string.IsNullOrEmpty(keyValue) && string.IsNullOrEmpty(keyNote)) continue; var rowKeyEntry = keyColumn.PullKey(keyValue, keyNote); // Ignore duplicate ids (LOC-464) if (addedIds.TryGetValue(rowKeyEntry.Id, out int duplicateRowId)) { messages.AppendLine($"An entry with the Id {rowKeyEntry.Id} has already been processed at row {duplicateRowId}, The entry {keyValue} at row {row} will be ignored."); continue; } // Rename duplicate names with unique ids. if (addedKeys.TryGetValue(rowKeyEntry.Key, out int duplicateRowKey)) { string newName = $"{rowKeyEntry.Key}_{rowKeyEntry.Id}"; messages.AppendLine($"An entry with the name `{rowKeyEntry.Key}` has already been processed at row {duplicateRowKey}, The entry {keyValue} at row {row} has been renamed to {newName}."); rowKeyEntry.Key = newName; } addedIds.Add(rowKeyEntry.Id, row); addedKeys.Add(rowKeyEntry.Key, row); sortedEntries.Add(rowKeyEntry); if (rowKeyEntry == null) { messages.AppendLine($"No key data was found for row {row} with Value '{keyValue}' and Note '{keyNote}'."); continue; } // Record the id so we can check what key ids were missing later. keysProcessed.Add(rowKeyEntry.Id); totalCellsProcessed++; for (int col = 1; col < columnMapping.Count; ++col) { string value = null; string note = null; var colRowData = columns[col].rowData; var valueIndex = columns[col].valueIndex; // Do we have data in this column for this row? if (colRowData != null && colRowData.Count > row && colRowData[row]?.Values?.Count > valueIndex) { var cellData = colRowData[row].Values[valueIndex]; if (cellData != null) { value = cellData.FormattedValue; note = cellData.Note; totalCellsProcessed++; } } // We always call PullCellData as its possible that data may have existed // in a previous Pull and has now been removed. We call Pull so that the column // is aware it is now null and can remove any metadata it may have added in the past. (LOC-134) columnMapping[col].PullCellData(rowKeyEntry, value, note); } } // Send the end message foreach (var col in columnMapping) { col.PullEnd(); } reporter?.ReportProgress("Removing missing entries and matching sheet row order", 0.9f); collection.MergeUpdatedEntries(keysProcessed, sortedEntries, messages, removeMissingEntries); reporter?.Completed($"Completed merge of {rowCount} rows and {totalCellsProcessed} cells from {columnMapping.Count} columns successfully.\n{messages.ToString()}"); } void ThrowIfDuplicateColumnIds(IList columnMapping) { var ids = new HashSet(); foreach (var col in columnMapping) { if (ids.Contains(col.Column)) throw new Exception($"Duplicate column found. The Column {col.Column} is already in use"); ids.Add(col.Column); } } void SetupSheet(string spreadSheetId, int sheetId, NewSheetProperties newSheetProperties) { var requests = new List(); requests.Add(SetTitleStyle(sheetId, newSheetProperties)); if (newSheetProperties.FreezeTitleRowAndKeyColumn) requests.Add(FreezeTitleRowAndKeyColumn(sheetId)); if (newSheetProperties.HighlightDuplicateKeys) requests.Add(HighlightDuplicateKeys(sheetId, newSheetProperties)); if (requests.Count > 0) SendBatchUpdateRequest(spreadSheetId, requests); } Request FreezeTitleRowAndKeyColumn(int sheetId) { return new Request() { UpdateSheetProperties = new UpdateSheetPropertiesRequest { Fields = "GridProperties.FrozenRowCount,GridProperties.FrozenColumnCount,", Properties = new SheetProperties { SheetId = sheetId, GridProperties = new GridProperties { FrozenRowCount = 1, FrozenColumnCount = 1 } } } }; } Request HighlightDuplicateKeys(int sheetId, NewSheetProperties newSheetProperties) { return new Request { // Highlight duplicates in the A(Key) field AddConditionalFormatRule = new AddConditionalFormatRuleRequest { Rule = new ConditionalFormatRule { BooleanRule = new BooleanRule { Condition = new BooleanCondition { Type = "CUSTOM_FORMULA", Values = new[] { new ConditionValue { UserEnteredValue = "=countif(A:A;A1)>1" } } }, Format = new CellFormat { BackgroundColor = UnityColorToDataColor(newSheetProperties.DuplicateKeyColor) } }, Ranges = new[] { new GridRange { SheetId = sheetId, EndColumnIndex = 1 } } } }, }; } Request SetTitleStyle(int sheetId, NewSheetProperties newSheetProperties) { return new Request { // Header style RepeatCell = new RepeatCellRequest { Fields = "*", Range = new GridRange { SheetId = sheetId, StartRowIndex = 0, EndRowIndex = 1, }, Cell = new CellData { UserEnteredFormat = new CellFormat { BackgroundColor = UnityColorToDataColor(newSheetProperties.HeaderBackgroundColor), TextFormat = new TextFormat { Bold = true, ForegroundColor = UnityColorToDataColor(newSheetProperties.HeaderForegroundColor) } } } } }; } Request ResizeRow(int sheetId, int newSize) { return new Request { UpdateSheetProperties = new UpdateSheetPropertiesRequest { Properties = new SheetProperties { SheetId = sheetId, GridProperties = new GridProperties { RowCount = newSize }, }, Fields = "gridProperties.rowCount" } }; } static Data.Color UnityColorToDataColor(UnityEngine.Color color) => new Data.Color() { Red = color.r, Green = color.g, Blue = color.b, Alpha = color.a }; internal protected virtual Task SendBatchUpdateRequestAsync(string spreadsheetId, IList requests) { var service = SheetsService.Service; var requestBody = new BatchUpdateSpreadsheetRequest { Requests = requests }; var batchUpdateReq = service.Spreadsheets.BatchUpdate(requestBody, spreadsheetId); return batchUpdateReq.ExecuteAsync(); } internal protected virtual BatchUpdateSpreadsheetResponse SendBatchUpdateRequest(string spreadsheetId, IList requests) { var service = SheetsService.Service; var requestBody = new BatchUpdateSpreadsheetRequest { Requests = requests }; var batchUpdateReq = service.Spreadsheets.BatchUpdate(requestBody, spreadsheetId); return batchUpdateReq.Execute(); } internal protected virtual BatchUpdateSpreadsheetResponse SendBatchUpdateRequest(string spreadsheetId, params Request[] requests) { var service = SheetsService.Service; var requestBody = new BatchUpdateSpreadsheetRequest { Requests = requests }; var batchUpdateReq = service.Spreadsheets.BatchUpdate(requestBody, spreadsheetId); return batchUpdateReq.Execute(); } internal protected virtual Task ExecuteRequestAsync(TClientServiceRequest req) where TClientServiceRequest : ClientServiceRequest => req.ExecuteAsync(); internal protected virtual TResponse ExecuteRequest(TClientServiceRequest req) where TClientServiceRequest : ClientServiceRequest => req.Execute(); } }