2025-05-01 01:48:08 -07:00

837 lines
40 KiB
C#

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
{
/// <summary>
/// Provides an interface for syncing localization data to a Google Sheet.
/// </summary>
public class GoogleSheets
{
/// <summary>
/// The sheets provider is responsible for providing the SheetsService and configuring the type of access.
/// <seealso cref="SheetsServiceProvider"/>.
/// </summary>
public IGoogleSheetsService SheetsService { get; private set; }
/// <summary>
/// The Id of the Google Sheet. This can be found by examining the url:
/// https://docs.google.com/spreadsheets/d/<b>SpreadsheetId</b>/edit#gid=<b>SheetId</b>
/// Further information can be found <see href="https://developers.google.com/sheets/api/guides/concepts#spreadsheet_id">here.</see>
/// </summary>
public string SpreadSheetId { get; set; }
/// <summary>
/// Is an API key being used or is it an OAuth?
/// </summary>
internal protected virtual bool UsingApiKey => (SheetsService as SheetsServiceProvider)?.Authentication != AuthenticationType.OAuth;
/// <summary>
/// Creates a new instance of a GoogleSheets connection.
/// </summary>
/// <param name="provider">The Google Sheets service provider. See <see cref="SheetsServiceProvider"/> for a default implementation.</param>
public GoogleSheets(IGoogleSheetsService provider)
{
if (provider == null)
throw new ArgumentNullException(nameof(provider));
SheetsService = provider;
}
/// <summary>
/// Opens the spreadsheet in a browser.
/// </summary>
/// <param name="spreadSheetId"></param>
public static void OpenSheetInBrowser(string spreadSheetId) => Application.OpenURL($"https://docs.google.com/spreadsheets/d/{spreadSheetId}/");
/// <summary>
/// Opens the spreadsheet with the sheet selected in a browser.
/// </summary>
/// <param name="spreadSheetId"></param>
/// <param name="sheetId"></param>
public static void OpenSheetInBrowser(string spreadSheetId, int sheetId) => Application.OpenURL($"https://docs.google.com/spreadsheets/d/{spreadSheetId}/#gid={sheetId}");
/// <summary>
/// Creates a new Google Spreadsheet.
/// </summary>
/// <param name="spreadSheetTitle">The title of the Spreadsheet.</param>
/// <param name="sheetTitle">The title of the sheet(tab) that is part of the Spreadsheet.</param>
/// <param name="newSheetProperties"></param>
/// <param name="reporter">Optional reporter to display the progress and status of the task.</param>
/// <returns>Returns the new Spreadsheet and sheet id.</returns>
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<Spreadsheet, CreateRequest>(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;
}
}
/// <summary>
/// Creates a new sheet within the Spreadsheet with the id <see cref="SpreadSheetId"/>.
/// </summary>
/// <param name="title">The title for the new sheet</param>
/// <param name="newSheetProperties">The settings to apply to the new sheet.</param>
/// <returns>The new sheet id.</returns>
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;
}
/// <summary>
/// Returns a list of all the sheets in the Spreadsheet with the id <see cref="SpreadSheetId"/>.
/// </summary>
/// <returns>The sheets names and id's.</returns>
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<Spreadsheet, GetRequest>(spreadsheetInfoRequest);
foreach (var sheet in sheetInfoReq.Sheets)
{
sheets.Add((sheet.Properties.Title, sheet.Properties.SheetId.Value));
}
return sheets;
}
/// <summary>
/// Returns all the column titles(values from the first row) for the selected sheet inside of the Spreadsheet with id <see cref="SpreadSheetId"/>.
/// This method requires the <see cref="SheetsService"/> to use OAuth authorization as it uses a data filter which reuires elevated authorization.
/// </summary>
/// <param name="sheetId">The sheet id.</param>
/// <returns>All the </returns>
public IList<string> 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<BatchGetValuesByDataFilterResponse, ValuesResource.BatchGetByDataFilterRequest>(request);
var titles = new List<string>();
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;
}
/// <summary>
/// Asynchronous version of <see cref="GetRowCount"/>
/// <inheritdoc cref="GetRowCount"/>
/// </summary>
/// <param name="sheetId">The sheet to get the row count from</param>
/// <returns>The row count for the sheet.</returns>
public async Task<int> GetRowCountAsync(int sheetId)
{
var rowCountRequest = GenerateGetRowCountRequest(sheetId);
var task = ExecuteRequestAsync<Spreadsheet, GetByDataFilterRequest>(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;
}
/// <summary>
/// Returns the total number of rows in the sheet inside of the Spreadsheet with id <see cref="SpreadSheetId"/>.
/// This method requires the <see cref="SheetsService"/> to use OAuth authorization as it uses a data filter which reuires elevated authorization.
/// </summary>
/// <param name="sheetId">The sheet to get the row count from.</param>
/// <returns>The row count for the sheet.</returns>
public int GetRowCount(int sheetId)
{
var rowCountRequest = GenerateGetRowCountRequest(sheetId);
var response = ExecuteRequest<Spreadsheet, GetByDataFilterRequest>(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);
}
/// <summary>
/// Asynchronous version of <see cref="PushStringTableCollection"/>
/// <inheritdoc cref="PushStringTableCollection"/>
/// </summary>
/// <param name="sheetId">The sheet(Spreadsheet tab) to insert the data into.</param>
/// <param name="collection">The collection to extract the data from.</param>
/// <param name="columnMapping">The column mappings control what data will be extracted for each column of the sheet. The list must contain 1 <see cref="KeyColumn"/>.</param>
/// <param name="reporter">Optional reporter to display the progress and status of the task.</param>
/// <returns></returns>
public async Task PushStringTableCollectionAsync(int sheetId, StringTableCollection collection, IList<SheetColumn> 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<Request>();
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;
}
}
/// <summary>
/// Extracts data from <paramref name="collection"/> using <paramref name="columnMapping"/> and sends it to the sheet
/// inside of the Spreadsheet with id <see cref="SpreadSheetId"/>.
/// This method requires the <see cref="SheetsService"/> to use OAuth authorization as an API Key does not have the ability to write to a sheet.
/// </summary>
/// <param name="sheetId">The sheet(Spreadsheet tab) to insert the data into.</param>
/// <param name="collection">The collection to extract the data from.</param>
/// <param name="columnMapping">The column mappings control what data will be extracted for each column of the sheet. The list must contain 1 <see cref="KeyColumn"/>.</param>
/// <param name="reporter">Optional reporter to display the progress and status of the task.</param>
/// <example>
/// A <see cref="StringTableCollection"/> can exist over several Google Sheets, for example one per Locale.
/// This example shows to push one of those Locales.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="push-collection-english"/>
/// </example>
/// <example>
/// This example shows how to push all the locales in your project by using <see cref="ColumnMapping"/> to generate the column mapping data for you.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="push-project-locales"/>
/// </example>
/// <example>
/// This example shows how to use the data that was configured in the <see cref="GoogleSheetsExtension"/> to perform a push.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="push-with-extension"/>
/// </example>
/// <example>
/// This example shows how to push every <see cref="StringTableCollection"/> that contains a <see cref="GoogleSheetsExtension"/>.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="push-all-extensions"/>
/// </example>
public void PushStringTableCollection(int sheetId, StringTableCollection collection, IList<SheetColumn> 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<Request>();
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<SheetColumn> columnMapping, List<Request> 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<PushColumnSheetRequest>(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<ExcludeEntryFromExport>())
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);
}
}
/// <summary>
/// Pulls data from the Spreadsheet with id <see cref="SpreadSheetId"/> and uses <paramref name="columnMapping"/>
/// to populate the <paramref name="collection"/>.
/// </summary>
/// <param name="sheetId">The sheet(Spreadsheet tab) to pull the data from.</param>
/// <param name="collection">The collection to insert the data into.</param>
/// <param name="columnMapping">The column mappings control what data to extract for each column of the sheet. The list must contain one <see cref="IPullKeyColumn"/>.</param>
/// <param name="removeMissingEntries">After a pull has completed, any keys that exist in the <paramref name="collection"/> 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.</param>
/// <param name="reporter">Optional reporter to display the progress and status of the task.</param>
/// <param name="createUndo">Should an Undo be recorded so any changes can be reverted?</param>
/// <example>
/// A <see cref="StringTableCollection"/> can exist over several Google Sheets, for example one per Locale.
/// This example shows how to pull one of those Locales into a <see cref="StringTableCollection"/>.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="pull-collection-english"/>
/// </example>
/// <example>
/// This example shows how to pull all the locales in your project by using the <see cref="ColumnMapping"/> to generate the column mapping data for you.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="pull-project-locales"/>
/// </example>
/// <example>
/// This example shows how to use the data that was configured in a Google Sheets extension to perform a pull.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="pull-with-extension"/>
/// </example>
/// <example>
/// This example shows how to pull every <see cref="StringTableCollection"/> that contains a <see cref="GoogleSheetsExtension"/>.
/// <code source="../../../DocCodeSamples.Tests/GoogleSheetsSamples.cs" region="pull-all-extensions"/>
/// </example>
public void PullIntoStringTableCollection(int sheetId, StringTableCollection collection, IList<SheetColumn> 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<Spreadsheet> pullReq = UsingApiKey ? GeneratePullRequest() : GenerateFilteredPullRequest(sheetId, columnMapping);
reporter?.ReportProgress("Sending request", 0.2f);
var response = ExecuteRequest<Spreadsheet, ClientServiceRequest<Spreadsheet>>(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> 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<SheetColumn> 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<Spreadsheet> 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<Spreadsheet> GenerateFilteredPullRequest(int sheetId, IList<SheetColumn> columnMapping)
{
var getRequest = new GetSpreadsheetByDataFilterRequest { DataFilters = new List<DataFilter>() };
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> rowData, int valueIndex)> columns, StringTableCollection collection, IList<SheetColumn> 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<long>();
// We want to keep track of the order the entries are pulled in so we can match it
var sortedEntries = new List<SharedTableEntry>(rowCount);
var addedIds = new Dictionary<long, int>(); // So we dont add duplicates. (id,row)
var addedKeys = new Dictionary<string, int>(); // 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<SheetColumn> columnMapping)
{
var ids = new HashSet<string>();
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<Request>();
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<BatchUpdateSpreadsheetResponse> SendBatchUpdateRequestAsync(string spreadsheetId, IList<Request> 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<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 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<TResponse> ExecuteRequestAsync<TResponse, TClientServiceRequest>(TClientServiceRequest req) where TClientServiceRequest : ClientServiceRequest<TResponse> => req.ExecuteAsync();
internal protected virtual TResponse ExecuteRequest<TResponse, TClientServiceRequest>(TClientServiceRequest req) where TClientServiceRequest : ClientServiceRequest<TResponse> => req.Execute();
}
}