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 { /// /// 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. /// 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 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(); if (tabs.Count == 0) { throw new SheetsExportException(400, "No table to write."); } Validate(tabs); var warnings = new List(); 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()).Concat(warnings).ToList(); return result; } #region Validation private void Validate(List 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(); // "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(); 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 /// /// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14" /// stays readable as "Ranking 13-14". /// private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled); private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled); private const int MaxTitleLength = 100; /// /// 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. /// private static void NameTabs(List tabs, List warnings) { var taken = new HashSet(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; } } /// /// 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. /// 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 } }