using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Google; using Google.Apis.Auth.OAuth2; using Google.Apis.Services; using Google.Apis.Sheets.v4; using Google.Apis.Sheets.v4.Data; using LaDOSE.Business.Interface; using LaDOSE.Entity; namespace LaDOSE.Business.Provider.SheetsProvider { /// /// Writes to Google Sheets as a service account. /// /// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is /// atomic — either every tab lands or none does — so a failure can never leave the ranking /// half updated. /// /// Setup, once: create a service account, download its JSON key, point /// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the /// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people /// forget, so a 403 says so explicitly and names the address. /// public class GoogleApiSheetsWriter : ISheetsWriter { private readonly string _credentialsPath; public GoogleApiSheetsWriter(string credentialsPath) { _credentialsPath = credentialsPath; } public string Name => "ServiceAccount"; public bool IsConfigured => !string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath); public async Task WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default) { if (!IsConfigured) { throw new SheetsExportException(503, $"Service-account key not found at '{_credentialsPath}'. " + "Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted."); } using var service = CreateService(out var accountEmail); var tabs = request.Tabs ?? new List(); try { // 1. What is already in the spreadsheet: titles, ids, and current grid sizes. var get = service.Spreadsheets.Get(request.SpreadsheetId); get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)"; var spreadsheet = await get.ExecuteAsync(ct); var existing = (spreadsheet.Sheets ?? new List()) .Select(sheet => sheet.Properties) .Where(properties => properties != null) .ToList(); // 2. One atomic batch for the whole export. var batch = BuildBatch(existing, tabs, out var results); if (batch.Requests.Count > 0) { await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct); } return new SheetExportResult { SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId, SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId), Writer = Name, Tabs = results, Warnings = new List() }; } catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden) { throw new SheetsExportException(502, $"Google refused access to spreadsheet '{request.SpreadsheetId}'. " + $"Share it as Editor with {accountEmail ?? "the service account address"}.", ex); } catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) { throw new SheetsExportException(502, $"No spreadsheet with id '{request.SpreadsheetId}'. " + "Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex); } catch (GoogleApiException ex) { throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex); } } /// /// Builds the credential from the key file explicitly. GoogleCredential.FromFile and /// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client /// initializer, so there is nothing for GoogleCredential to add here. Doing it this way /// also hands us the account's own address for the "share the sheet with…" message. /// private SheetsService CreateService(out string accountEmail) { ServiceAccountCredential credential; try { var json = File.ReadAllText(_credentialsPath); var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance .Deserialize(json); if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType || string.IsNullOrEmpty(parameters.ClientEmail) || string.IsNullOrEmpty(parameters.PrivateKey)) { throw new InvalidOperationException( "not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)"); } accountEmail = parameters.ClientEmail; credential = new ServiceAccountCredential( new ServiceAccountCredential.Initializer(parameters.ClientEmail) { ProjectId = parameters.ProjectId, KeyId = parameters.PrivateKeyId, Scopes = new[] { SheetsService.Scope.Spreadsheets } }.FromPrivateKey(parameters.PrivateKey)); } catch (Exception ex) { throw new SheetsExportException(503, $"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex); } return new SheetsService(new BaseClientService.Initializer { HttpClientInitializer = credential, ApplicationName = "LaDOSE" }); } #region Batch construction /// /// Turns "what the spreadsheet has" plus "what we want" into one ordered request list. /// Deliberately static and free of I/O so the whole batch can be asserted in a test with /// no network — the same split StatisticsService uses for its pure Aggregate. /// /// Order matters: create missing tabs, resize, reindex, then per tab clear and write. /// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts. /// public static BatchUpdateSpreadsheetRequest BuildBatch( List existing, List tabs, out List results) { var requests = new List(); results = new List(); var byTitle = (existing ?? new List()) .Where(properties => properties.Title != null) .GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); // Ids for tabs that do not exist yet are only known after the batch runs, so the // clear and write for those address the sheet by title through a placeholder id. // AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves. var nextId = NextFreeSheetId(byTitle.Values); for (var index = 0; index < tabs.Count; index++) { var tab = tabs[index]; var values = ToRowData(tab); var rowCount = values.Count; var columnCount = tab.Header?.Count ?? 0; byTitle.TryGetValue(tab.Name, out var properties); var created = properties == null; int sheetId; if (created) { sheetId = nextId++; requests.Add(new Request { AddSheet = new AddSheetRequest { Properties = new SheetProperties { SheetId = sheetId, Title = tab.Name, Index = index, GridProperties = new GridProperties { RowCount = Math.Max(rowCount, 1), ColumnCount = Math.Max(columnCount, 1), FrozenRowCount = 1 } } } }); } else { sheetId = properties.SheetId ?? 0; // Grow before writing: the API silently drops cells outside the grid, and a // sheet created by hand defaults to 1000 x 26. var haveRows = properties.GridProperties?.RowCount ?? 0; var haveColumns = properties.GridProperties?.ColumnCount ?? 0; if (haveRows < rowCount || haveColumns < columnCount) { requests.Add(new Request { UpdateSheetProperties = new UpdateSheetPropertiesRequest { Fields = "gridProperties.rowCount,gridProperties.columnCount", Properties = new SheetProperties { SheetId = sheetId, GridProperties = new GridProperties { RowCount = Math.Max(haveRows, rowCount), ColumnCount = Math.Max(haveColumns, columnCount) } } } }); } // Oldest event leftmost, so the tabs read in season order. if (properties.Index != index) { requests.Add(new Request { UpdateSheetProperties = new UpdateSheetPropertiesRequest { Fields = "index", Properties = new SheetProperties { SheetId = sheetId, Index = index } } }); } } // Clear the whole sheet first, then write. Fields = "userEnteredValue" is the // equivalent of clearContents(): colours, notes and conditional formatting the // user set up by hand all survive. Without the clear, cells beyond the new // extent would linger from a previous, longer export. requests.Add(new Request { UpdateCells = new UpdateCellsRequest { Range = new GridRange { SheetId = sheetId }, Fields = "userEnteredValue" } }); requests.Add(new Request { UpdateCells = new UpdateCellsRequest { Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 }, Rows = values, Fields = "userEnteredValue" } }); results.Add(new SheetTabResult { RequestedName = tab.Name, Name = tab.Name, Rows = rowCount, Columns = columnCount, Created = created }); } return new BatchUpdateSpreadsheetRequest { Requests = requests }; } /// Sheet ids must be unique within the spreadsheet and are ours to choose. private static int NextFreeSheetId(IEnumerable existing) { var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max(); return Math.Max(used + 1, 1); } /// /// Header, then one row per player, then the footer after a blank line. Points and totals /// go in as numbers rather than text so the user's formulas keep working. /// private static List ToRowData(SheetTable tab) { var header = tab.Header ?? new List(); var width = header.Count; var rows = new List { new RowData { Values = header.Select(Text).ToList() } }; foreach (var row in tab.Rows ?? new List()) { var cells = new List { Text(row.Player) }; cells.AddRange((row.Points ?? new List()).Select(Number)); cells.Add(Number(row.Total)); rows.Add(new RowData { Values = cells }); } var footer = (tab.Footer ?? new List()).Where(line => line != null).ToList(); if (footer.Count > 0) { rows.Add(new RowData { Values = Blank(width) }); foreach (var line in footer) { var cells = Blank(width); if (cells.Count > 0) cells[0] = Text(line); else cells.Add(Text(line)); rows.Add(new RowData { Values = cells }); } } return rows; } private static List Blank(int width) { return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList(); } private static CellData Text(string value) { return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } }; } private static CellData Number(int value) { return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } }; } #endregion } }