This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
public interface ISheetsExportService
|
||||
{
|
||||
/// <summary>What the export panel needs to render. Never includes a secret.</summary>
|
||||
SheetsConfig GetConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the target spreadsheet from configuration, validates the tables, sanitises
|
||||
/// and de-duplicates the tab titles, then hands off to the configured
|
||||
/// <see cref="ISheetsWriter"/>. Throws <see cref="SheetsExportException"/> for anything
|
||||
/// the caller can act on.
|
||||
/// </summary>
|
||||
Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A failure with a message meant for the person who pressed the button, plus the status
|
||||
/// the API should answer with. The API has no exception middleware, so anything that escapes
|
||||
/// reaches the browser as an HTML developer page the client can only report as a bare status
|
||||
/// — hence every foreseeable failure is raised as one of these instead.
|
||||
/// </summary>
|
||||
public class SheetsExportException : Exception
|
||||
{
|
||||
public SheetsExportException(int statusCode, string message) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public SheetsExportException(int statusCode, string message, Exception inner) : base(message, inner)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public int StatusCode { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes tabular data into a Google Spreadsheet. Startup picks one implementation from
|
||||
/// GoogleSheets:Writer, so nothing above this interface knows how Google is reached.
|
||||
///
|
||||
/// Implementations must be idempotent: writing the same request twice leaves the same
|
||||
/// spreadsheet. They must also leave tabs they were not asked about completely alone —
|
||||
/// the spreadsheet holds hand-made summaries and charts, and deletion is irreversible
|
||||
/// through the API.
|
||||
/// </summary>
|
||||
public interface ISheetsWriter
|
||||
{
|
||||
/// <summary>Reported to the UI so the panel can say what it is talking to.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>False when credentials are missing, so the API can answer 503 with a message.</summary>
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,19 @@ namespace LaDOSE.Business.Interface
|
||||
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||
/// </summary>
|
||||
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
||||
|
||||
/// <summary>
|
||||
/// The players a versus lookup can say something about: those with at least one
|
||||
/// set in a bracket whose game is known, by display name.
|
||||
/// </summary>
|
||||
Task<List<PlayerOption>> GetVersusPlayers();
|
||||
|
||||
/// <summary>
|
||||
/// Every recorded meeting between two players, all events, broken down per game.
|
||||
/// Sets whose bracket has no game are excluded and only counted in
|
||||
/// <see cref="PlayerVersus.UnknownGameSets"/>.
|
||||
/// Missing, equal or unknown ids yield a well-formed empty <see cref="PlayerVersus"/>.
|
||||
/// </summary>
|
||||
Task<PlayerVersus> GetVersus(int playerAId, int playerBId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
|
||||
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
|
||||
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
|
||||
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
|
||||
/// so the rules are stated once and can be tested without a network, the same way
|
||||
/// StatisticsService keeps its aggregation in a pure static method.
|
||||
/// </summary>
|
||||
public class SheetsExportService : ISheetsExportService
|
||||
{
|
||||
private readonly SheetsSettings _settings;
|
||||
private readonly ISheetsWriter _writer;
|
||||
|
||||
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
|
||||
{
|
||||
_settings = settings ?? new SheetsSettings();
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public SheetsConfig GetConfig()
|
||||
{
|
||||
return new SheetsConfig
|
||||
{
|
||||
Writer = _writer?.Name ?? "Disabled",
|
||||
Configured = _writer != null
|
||||
&& _writer.IsConfigured
|
||||
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
|
||||
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
|
||||
MaxTabs = _settings.MaxTabs,
|
||||
MaxRowsPerTab = _settings.MaxRowsPerTab
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (_writer == null || !_writer.IsConfigured)
|
||||
{
|
||||
throw new SheetsExportException(503,
|
||||
"Google Sheets export is not configured on the server.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
|
||||
}
|
||||
|
||||
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List<SheetTable>();
|
||||
if (tabs.Count == 0)
|
||||
{
|
||||
throw new SheetsExportException(400, "No table to write.");
|
||||
}
|
||||
|
||||
Validate(tabs);
|
||||
|
||||
var warnings = new List<string>();
|
||||
NameTabs(tabs, warnings);
|
||||
|
||||
// The caller never names the spreadsheet; it is resolved here, from configuration.
|
||||
var resolved = new SheetExportRequest
|
||||
{
|
||||
SpreadsheetId = _settings.SpreadsheetId.Trim(),
|
||||
Tabs = tabs
|
||||
};
|
||||
|
||||
var result = await _writer.WriteTablesAsync(resolved, ct);
|
||||
result.Writer = _writer.Name;
|
||||
result.Warnings = (result.Warnings ?? new List<string>()).Concat(warnings).ToList();
|
||||
return result;
|
||||
}
|
||||
|
||||
#region Validation
|
||||
|
||||
private void Validate(List<SheetTable> tabs)
|
||||
{
|
||||
if (tabs.Count > _settings.MaxTabs)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
|
||||
}
|
||||
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
var header = tab.Header ?? new List<string>();
|
||||
|
||||
// "Players", at least one game, "Total".
|
||||
if (header.Count < 3)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
|
||||
}
|
||||
|
||||
if (header.Count > _settings.MaxColumns)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
|
||||
}
|
||||
|
||||
var rows = tab.Rows ?? new List<SheetRow>();
|
||||
if (rows.Count > _settings.MaxRowsPerTab)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
|
||||
}
|
||||
|
||||
var expected = header.Count - 2;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var points = row?.Points?.Count ?? 0;
|
||||
if (points != expected)
|
||||
{
|
||||
throw new SheetsExportException(400,
|
||||
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
|
||||
$"header declares {expected}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tab naming
|
||||
|
||||
/// <summary>
|
||||
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
|
||||
/// stays readable as "Ranking 13-14".
|
||||
/// </summary>
|
||||
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private const int MaxTitleLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Makes every title legal and unique, in place, recording each change. Tab identity is
|
||||
/// the title, so a rename means the next export writes somewhere else — which is exactly
|
||||
/// why every rename is reported rather than applied quietly.
|
||||
/// </summary>
|
||||
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
|
||||
{
|
||||
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tab in tabs)
|
||||
{
|
||||
var requested = tab.Name ?? string.Empty;
|
||||
var name = Sanitise(requested, tab.EventId);
|
||||
|
||||
if (!taken.Add(name))
|
||||
{
|
||||
var suffix = 2;
|
||||
string candidate;
|
||||
do
|
||||
{
|
||||
candidate = Truncate($"{name} ({suffix})");
|
||||
suffix++;
|
||||
} while (!taken.Add(candidate));
|
||||
|
||||
name = candidate;
|
||||
}
|
||||
|
||||
if (!string.Equals(name, requested, StringComparison.Ordinal))
|
||||
{
|
||||
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
|
||||
}
|
||||
|
||||
tab.Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
|
||||
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
|
||||
/// back to the event id when nothing usable survives.
|
||||
/// </summary>
|
||||
public static string Sanitise(string requested, int eventId)
|
||||
{
|
||||
var name = (requested ?? string.Empty).Trim();
|
||||
name = Forbidden.Replace(name, "-");
|
||||
name = Whitespace.Replace(name, " ").Trim();
|
||||
name = name.Trim('\'');
|
||||
name = Truncate(name).Trim();
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
|
||||
}
|
||||
|
||||
private static string Truncate(string value)
|
||||
{
|
||||
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service
|
||||
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
||||
/// encodes a DQ as -1, so games are clamped at 0.
|
||||
///
|
||||
/// - A Set has no game either. The game belongs to the <see cref="Tournament"/> the set
|
||||
/// was played in, and Tournament.GameId is nullable, so any per-game breakdown has
|
||||
/// to decide what to do with brackets that have none. <see cref="GetVersus"/> drops
|
||||
/// them and reports how many it dropped.
|
||||
///
|
||||
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||
/// issues one query per table and hands the loaded lists to the pure static
|
||||
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
||||
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
|
||||
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
|
||||
/// without a database.
|
||||
/// </summary>
|
||||
public class StatisticsService : IStatisticsService
|
||||
{
|
||||
@@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service
|
||||
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||
}
|
||||
|
||||
public Task<List<PlayerOption>> GetVersusPlayers()
|
||||
{
|
||||
// Same filter GetVersus applies, so the picker cannot offer a player whose
|
||||
// every meeting would then be dropped as "game unknown". Self-sets are
|
||||
// excluded here too, otherwise they would be counted in both slots.
|
||||
var usable = from s in _context.Set
|
||||
join t in _context.Tournament on s.TournamentId equals t.Id
|
||||
where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0
|
||||
&& s.Player1Id != s.Player2Id
|
||||
select s;
|
||||
|
||||
// Counted in the database, one group-by per slot: the set table is the
|
||||
// largest one here and there is no reason to pull it into memory.
|
||||
var asPlayer1 = usable
|
||||
.GroupBy(s => s.Player1Id)
|
||||
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var asPlayer2 = usable
|
||||
.GroupBy(s => s.Player2Id)
|
||||
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var setsByPlayer = new Dictionary<int, int>();
|
||||
foreach (var row in asPlayer1.Concat(asPlayer2))
|
||||
{
|
||||
setsByPlayer.TryGetValue(row.PlayerId, out var running);
|
||||
setsByPlayer[row.PlayerId] = running + row.Sets;
|
||||
}
|
||||
|
||||
if (setsByPlayer.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new List<PlayerOption>());
|
||||
}
|
||||
|
||||
var playerIds = setsByPlayer.Keys.ToList();
|
||||
var nameById = _context.Player
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.ToList()
|
||||
.ToDictionary(p => p.Id, DisplayName);
|
||||
|
||||
// A set can reference a player row that no longer exists; keep it as "#id"
|
||||
// rather than hiding a real opponent from the picker.
|
||||
var options = setsByPlayer
|
||||
.Select(pair => new PlayerOption
|
||||
{
|
||||
Id = pair.Key,
|
||||
Name = ResolveName(pair.Key, nameById),
|
||||
Sets = pair.Value
|
||||
})
|
||||
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(p => p.Id)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(options);
|
||||
}
|
||||
|
||||
public Task<PlayerVersus> GetVersus(int playerAId, int playerBId)
|
||||
{
|
||||
// Nothing to look up, and nothing exceptional either: a caller that has not
|
||||
// picked two distinct players gets an empty breakdown, not a 500.
|
||||
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||
{
|
||||
return Task.FromResult(new PlayerVersus
|
||||
{
|
||||
PlayerAId = playerAId,
|
||||
PlayerBId = playerBId
|
||||
});
|
||||
}
|
||||
|
||||
// Either seating: the set rows record whoever start.gg listed first.
|
||||
var sets = _context.Set
|
||||
.Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId)
|
||||
|| (s.Player1Id == playerBId && s.Player2Id == playerAId))
|
||||
.ToList();
|
||||
|
||||
var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList();
|
||||
var tournaments = tournamentIds.Count == 0
|
||||
? new List<Tournament>()
|
||||
: _context.Tournament
|
||||
.Where(t => tournamentIds.Contains(t.Id))
|
||||
.ToList();
|
||||
|
||||
var gameIds = tournaments
|
||||
.Where(t => t.GameId.HasValue)
|
||||
.Select(t => t.GameId.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var games = gameIds.Count == 0
|
||||
? new List<Game>()
|
||||
: _context.Game
|
||||
.Where(g => gameIds.Contains(g.Id))
|
||||
.ToList();
|
||||
|
||||
var players = _context.Player
|
||||
.Where(p => p.Id == playerAId || p.Id == playerBId)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||
/// </summary>
|
||||
@@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation of the meetings between two players, per game. No database,
|
||||
/// no I/O, deterministic.
|
||||
///
|
||||
/// Winners are inferred from the scores exactly as in <see cref="Aggregate"/>: equal
|
||||
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
|
||||
/// but contribute to no win and no game count.
|
||||
/// </summary>
|
||||
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
|
||||
/// <param name="playerBId">Right-hand player.</param>
|
||||
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
|
||||
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
|
||||
/// <param name="games">Games used to resolve names; may be incomplete.</param>
|
||||
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||
public static PlayerVersus AggregateVersus(
|
||||
int playerAId,
|
||||
int playerBId,
|
||||
IEnumerable<Set> sets,
|
||||
IEnumerable<Tournament> tournaments,
|
||||
IEnumerable<Game> games,
|
||||
IEnumerable<Player> players)
|
||||
{
|
||||
var result = new PlayerVersus
|
||||
{
|
||||
PlayerAId = playerAId,
|
||||
PlayerBId = playerBId
|
||||
};
|
||||
|
||||
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var nameById = new Dictionary<int, string>();
|
||||
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||
{
|
||||
nameById[player.Id] = DisplayName(player);
|
||||
}
|
||||
|
||||
result.PlayerA = ResolveName(playerAId, nameById);
|
||||
result.PlayerB = ResolveName(playerBId, nameById);
|
||||
|
||||
var gameIdByTournament = new Dictionary<int, int?>();
|
||||
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
|
||||
{
|
||||
gameIdByTournament[tournament.Id] = tournament.GameId;
|
||||
}
|
||||
|
||||
var gameById = new Dictionary<int, Game>();
|
||||
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
|
||||
{
|
||||
gameById[game.Id] = game;
|
||||
}
|
||||
|
||||
var perGame = new Dictionary<int, VersusGameStats>();
|
||||
|
||||
foreach (var set in (sets ?? Enumerable.Empty<Set>()).Where(s => s != null))
|
||||
{
|
||||
// The query already restricts the pairing; checking again keeps this
|
||||
// method correct on its own, which is the point of it being pure.
|
||||
var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId;
|
||||
var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId;
|
||||
if (!aIsPlayer1 && !bIsPlayer1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// No game on the bracket, nothing to file this meeting under. Counted so
|
||||
// the caller can say "3 meetings we cannot attribute" instead of losing them.
|
||||
if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue)
|
||||
{
|
||||
result.UnknownGameSets++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = GetOrAddGame(perGame, gameId.Value, gameById);
|
||||
row.Sets++;
|
||||
|
||||
var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score;
|
||||
var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score;
|
||||
|
||||
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||
if (scoreA == scoreB)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
row.DecidedSets++;
|
||||
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||
row.GamesWonA += Math.Max(0, scoreA);
|
||||
row.GamesWonB += Math.Max(0, scoreB);
|
||||
if (scoreA > scoreB)
|
||||
{
|
||||
row.WinsA++;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.WinsB++;
|
||||
}
|
||||
}
|
||||
|
||||
result.Games = perGame.Values
|
||||
.OrderByDescending(g => g.Sets)
|
||||
.ThenByDescending(g => g.DecidedSets)
|
||||
.ThenBy(g => g.Game, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.GameId)
|
||||
.ToList();
|
||||
|
||||
// Totals are derived from the rows, so the header cannot disagree with the table.
|
||||
result.Sets = result.Games.Sum(g => g.Sets);
|
||||
result.DecidedSets = result.Games.Sum(g => g.DecidedSets);
|
||||
result.WinsA = result.Games.Sum(g => g.WinsA);
|
||||
result.WinsB = result.Games.Sum(g => g.WinsB);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static VersusGameStats GetOrAddGame(Dictionary<int, VersusGameStats> perGame, int gameId,
|
||||
Dictionary<int, Game> gameById)
|
||||
{
|
||||
if (!perGame.TryGetValue(gameId, out var row))
|
||||
{
|
||||
gameById.TryGetValue(gameId, out var game);
|
||||
var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name;
|
||||
row = new VersusGameStats
|
||||
{
|
||||
GameId = gameId,
|
||||
Game = name,
|
||||
GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName
|
||||
};
|
||||
perGame[gameId] = row;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||
Dictionary<int, string> nameById)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user