203 lines
7.3 KiB
C#
203 lines
7.3 KiB
C#
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
|
|
}
|
|
}
|