This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user