This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The default when GoogleSheets:Writer names nothing usable. Reports itself as
|
||||
/// unconfigured so the API answers 503 with a message the user can act on, rather than
|
||||
/// throwing a NullReferenceException that reaches the browser as an HTML developer page.
|
||||
/// </summary>
|
||||
public class DisabledSheetsWriter : ISheetsWriter
|
||||
{
|
||||
public string Name => "Disabled";
|
||||
|
||||
public bool IsConfigured => false;
|
||||
|
||||
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
throw new SheetsExportException(503,
|
||||
"Google Sheets export is not configured on the server. Set GoogleSheets:Writer.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SheetExportResult> 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<SheetTable>();
|
||||
|
||||
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<Sheet>())
|
||||
.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<string>()
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private SheetsService CreateService(out string accountEmail)
|
||||
{
|
||||
ServiceAccountCredential credential;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_credentialsPath);
|
||||
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
|
||||
.Deserialize<JsonCredentialParameters>(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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static BatchUpdateSpreadsheetRequest BuildBatch(
|
||||
List<SheetProperties> existing,
|
||||
List<SheetTable> tabs,
|
||||
out List<SheetTabResult> results)
|
||||
{
|
||||
var requests = new List<Request>();
|
||||
results = new List<SheetTabResult>();
|
||||
|
||||
var byTitle = (existing ?? new List<SheetProperties>())
|
||||
.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 };
|
||||
}
|
||||
|
||||
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
|
||||
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
|
||||
{
|
||||
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
|
||||
return Math.Max(used + 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<RowData> ToRowData(SheetTable tab)
|
||||
{
|
||||
var header = tab.Header ?? new List<string>();
|
||||
var width = header.Count;
|
||||
var rows = new List<RowData>
|
||||
{
|
||||
new RowData { Values = header.Select(Text).ToList() }
|
||||
};
|
||||
|
||||
foreach (var row in tab.Rows ?? new List<SheetRow>())
|
||||
{
|
||||
var cells = new List<CellData> { Text(row.Player) };
|
||||
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
|
||||
cells.Add(Number(row.Total));
|
||||
rows.Add(new RowData { Values = cells });
|
||||
}
|
||||
|
||||
var footer = (tab.Footer ?? new List<string>()).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<CellData> 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the export to the log instead of to Google, and reports success.
|
||||
///
|
||||
/// This exists so the whole feature — button, ordering, cumulative prefixes, payload shape —
|
||||
/// can be exercised end to end before any Google account, key or sharing exists. Set
|
||||
/// GoogleSheets:Writer to "Logging" and read the API log. It is also the safe way to try a
|
||||
/// selection without touching the real spreadsheet.
|
||||
/// </summary>
|
||||
public class LoggingSheetsWriter : ISheetsWriter
|
||||
{
|
||||
private readonly ILogger<LoggingSheetsWriter> _logger;
|
||||
|
||||
public LoggingSheetsWriter(ILogger<LoggingSheetsWriter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Name => "Logging";
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var tabs = request.Tabs ?? new List<SheetTable>();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sheets export (Logging writer): spreadsheet {SpreadsheetId}, {TabCount} tabs",
|
||||
request.SpreadsheetId, tabs.Count);
|
||||
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
var header = tab.Header ?? new List<string>();
|
||||
var rows = tab.Rows ?? new List<SheetRow>();
|
||||
|
||||
_logger.LogInformation(" [{Name}] event {EventId}: {RowCount} rows x {ColumnCount} columns | {Header}",
|
||||
tab.Name, tab.EventId, rows.Count, header.Count, string.Join(" | ", header));
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_logger.LogInformation(" {Player} | {Points} | {Total}",
|
||||
row.Player, string.Join(" | ", row.Points ?? new List<int>()), row.Total);
|
||||
}
|
||||
|
||||
foreach (var line in tab.Footer ?? new List<string>())
|
||||
{
|
||||
_logger.LogInformation(" -- {Line}", line);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new SheetExportResult
|
||||
{
|
||||
SpreadsheetId = request.SpreadsheetId,
|
||||
SpreadsheetUrl = SheetUrl(request.SpreadsheetId),
|
||||
Writer = Name,
|
||||
Tabs = tabs.Select(tab => new SheetTabResult
|
||||
{
|
||||
RequestedName = tab.Name,
|
||||
Name = tab.Name,
|
||||
Rows = (tab.Rows?.Count ?? 0) + 1,
|
||||
Columns = tab.Header?.Count ?? 0,
|
||||
// Nothing was inspected, so this is a claim rather than an observation.
|
||||
Created = true
|
||||
}).ToList(),
|
||||
Warnings = new List<string>
|
||||
{
|
||||
"Writer is 'Logging': nothing was written to Google. The payload is in the API log."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
internal static string SheetUrl(string spreadsheetId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(spreadsheetId)
|
||||
? null
|
||||
: $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user