Update EventOccurrence parsing to use EventOccurrenceParseGroup for improved data structure

This commit refactors the EventOccurrence parsing logic to utilize the EventOccurrenceParseGroup class, enhancing the organization of parsed occurrences by grouping them based on event definitions and optional section levels. The changes include updates to the EventOccurrenceParseResult, EventOccurrenceParser, and EventOccurrenceParserService to accommodate the new grouping structure. Additionally, tests are modified to reflect these changes, ensuring that the parsing functionality remains intact and accurate. This refactor improves data handling and aligns with the overall architecture of the application.
This commit is contained in:
2026-04-04 21:55:44 -04:00
parent 4dcd9e5aab
commit f400813667
39 changed files with 1896 additions and 93 deletions
@@ -0,0 +1,82 @@
using System.Text;
using Core.Entities;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Loads minimal <see cref="EventDefinition"/> rows from Tests-style CSV (column "Event").
/// </summary>
public static class EventDefinitionsCsvLoader
{
public static List<EventDefinition> Load(string path)
{
var list = new List<EventDefinition>();
var lines = File.ReadAllLines(path);
if (lines.Length < 2)
return list;
var header = ParseCsvLine(lines[0]);
var eventIdx = header.FindIndex(h => h.Equals("Event", StringComparison.OrdinalIgnoreCase));
if (eventIdx < 0)
throw new InvalidOperationException($"CSV '{path}' must contain an 'Event' column header.");
for (var i = 1; i < lines.Length; i++)
{
if (string.IsNullOrWhiteSpace(lines[i]))
continue;
var cols = ParseCsvLine(lines[i]);
if (eventIdx >= cols.Count)
continue;
var name = cols[eventIdx].Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
list.Add(new EventDefinition
{
Id = i,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
});
}
return list;
}
private static List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var cur = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
cur.Append('"');
i++;
}
else inQuotes = false;
}
else cur.Append(ch);
}
else
{
if (ch == '"') inQuotes = true;
else if (ch == ',')
{
result.Add(cur.ToString());
cur.Clear();
}
else cur.Append(ch);
}
}
result.Add(cur.ToString());
return result;
}
}
@@ -0,0 +1,54 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Collapses duplicate site-wide rows (same event name, date, time) that appear in every location column
/// (e.g. CURFEW shaded across all rooms) into a single line with no location — still under
/// <c>General Schedule</c>, which the parser maps to <see cref="Core.Entities.EventDefinition.GeneralSchedule"/>.
/// </summary>
public static class GlobalEventDeduplicator
{
private static readonly HashSet<string> BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase)
{
"CURFEW"
};
/// <summary>
/// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific.
/// </summary>
public static List<ParsedOccurrenceLine> Deduplicate(
IReadOnlyList<ParsedOccurrenceLine> lines,
IReadOnlyCollection<string>? extraSiteWideNames = null)
{
var siteWide = new HashSet<string>(BuiltinSiteWideNames, StringComparer.OrdinalIgnoreCase);
if (extraSiteWideNames != null)
{
foreach (var n in extraSiteWideNames)
{
if (!string.IsNullOrWhiteSpace(n))
siteWide.Add(n.Trim());
}
}
var seen = new HashSet<(string Name, string Month, int Day, string Time)>();
var ordered = lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol).ToList();
var result = new List<ParsedOccurrenceLine>(ordered.Count);
foreach (var line in ordered)
{
var name = TextNormalization.ForEmitLine(line.Name);
if (string.IsNullOrEmpty(name) || !siteWide.Contains(name))
{
result.Add(line);
continue;
}
var key = (name, line.Month, line.Day, line.TimeRange);
if (!seen.Add(key))
continue;
result.Add(line with { Location = string.Empty, Name = name });
}
return result;
}
}
@@ -0,0 +1,143 @@
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using Color = Google.Apis.Sheets.v4.Data.Color;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Fetches raw grid data via Sheets API (public API key).
/// </summary>
public sealed class GoogleSheetGridReader
{
private readonly SheetsService _service;
public GoogleSheetGridReader(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new ArgumentException("API key is required for Google Sheets access.", nameof(apiKey));
_service = new SheetsService(new Google.Apis.Services.BaseClientService.Initializer
{
ApiKey = apiKey,
ApplicationName = "TSA GoogleSheetsScheduleImport"
});
}
public Spreadsheet FetchSpreadsheet(string spreadsheetId)
{
var req = _service.Spreadsheets.Get(spreadsheetId);
req.IncludeGridData = true;
return req.Execute();
}
public static GridSheetModel BuildModel(Sheet sheet)
{
var title = sheet.Properties?.Title ?? "(untitled)";
var grid = sheet.Data?.FirstOrDefault();
if (grid?.RowData == null || grid.RowData.Count == 0)
{
return new GridSheetModel
{
SheetTitle = title,
Values = Array.Empty<string?[]>(),
BackgroundKeys = Array.Empty<string?[]>()
};
}
var rowCount = grid.RowData.Count;
var colCount = grid.RowData.Max(r => r.Values?.Count ?? 0);
var values = new string?[rowCount][];
var bg = new string?[rowCount][];
for (var r = 0; r < rowCount; r++)
{
values[r] = new string?[colCount];
bg[r] = new string?[colCount];
var row = grid.RowData[r];
for (var c = 0; c < colCount; c++)
{
string? text = null;
string? hex = null;
if (row.Values != null && c < row.Values.Count)
{
var cell = row.Values[c];
text = string.IsNullOrWhiteSpace(cell.FormattedValue)
? cell.EffectiveValue?.StringValue
: cell.FormattedValue;
if (!string.IsNullOrEmpty(text))
text = TextNormalization.ForSheetCell(text);
var color = cell.UserEnteredFormat?.BackgroundColor
?? cell.EffectiveFormat?.BackgroundColor;
hex = ColorToHexKey(color);
}
values[r][c] = string.IsNullOrWhiteSpace(text) ? null : text;
bg[r][c] = hex;
}
}
ApplyMerges(sheet.Merges, values, bg);
NormalizeAllValueCells(values);
return new GridSheetModel
{
SheetTitle = title,
Values = values,
BackgroundKeys = bg
};
}
private static void ApplyMerges(IList<GridRange>? merges, string?[][] values, string?[][] bg)
{
if (merges == null || merges.Count == 0)
return;
foreach (var range in merges)
{
var r0 = range.StartRowIndex ?? 0;
var r1 = range.EndRowIndex ?? r0;
var c0 = range.StartColumnIndex ?? 0;
var c1 = range.EndColumnIndex ?? c0;
if (r1 <= r0 || c1 <= c0)
continue;
var anchorText = values[r0][c0];
var anchorBg = bg[r0][c0];
for (var r = r0; r < r1; r++)
{
for (var c = c0; c < c1; c++)
{
if (values[r][c] == null && anchorText != null)
values[r][c] = anchorText;
if (bg[r][c] == null && anchorBg != null)
bg[r][c] = anchorBg;
}
}
}
}
/// <summary>Re-run after merges so copied anchor text is also single-line.</summary>
private static void NormalizeAllValueCells(string?[][] values)
{
for (var r = 0; r < values.Length; r++)
for (var c = 0; c < values[r].Length; c++)
{
if (values[r][c] is { } v && !string.IsNullOrWhiteSpace(v))
values[r][c] = TextNormalization.ForEmitLine(v);
}
}
private static string? ColorToHexKey(Color? color)
{
if (color == null)
return null;
var r = color.Red ?? 1f;
var g = color.Green ?? 1f;
var b = color.Blue ?? 1f;
// Treat near-white as no color key for grouping
if (r >= 0.99f && g >= 0.99f && b >= 0.99f)
return null;
static byte F(float x) => (byte)(Math.Clamp(x, 0f, 1f) * 255f);
return $"#{F(r):X2}{F(g):X2}{F(b):X2}";
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GoogleSheetsScheduleImport</AssemblyName>
<RootNamespace>GoogleSheetsScheduleImport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.70.0.3806" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored).
/// </summary>
public sealed class GridSheetModel
{
public required string SheetTitle { get; init; }
/// <summary>display values, sanitized hyphens; [row][col]</summary>
public required string?[][] Values { get; init; }
/// <summary>Optional RGB hex backgrounds for block grouping (#RRGGBB or null)</summary>
public required string?[][]? BackgroundKeys { get; init; }
public int RowCount => Values.Length;
public int ColCount => Values.Length == 0 ? 0 : Values[0].Length;
}
public readonly record struct ParsedOccurrenceLine(
string Name,
string Month,
int Day,
string TimeRange,
string Location,
int SourceRowStart,
int SourceRowEnd,
int SourceCol);
@@ -0,0 +1,91 @@
using System.Text;
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Emits PDF-style section headers <c>Event Name - MS</c> / <c>Event Name - HS</c> before occurrence lines,
/// matching competition schedule imports.
/// </summary>
public static class GroupedImportTextEmitter
{
private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable<SectionKey>
{
public int EventDefinitionId { get; } = eventDefinitionId;
public SchoolLevel Level { get; } = level;
public bool Equals(SectionKey other) =>
EventDefinitionId == other.EventDefinitionId && Level == other.Level;
public override bool Equals(object? obj) => obj is SectionKey other && Equals(other);
public override int GetHashCode() => HashCode.Combine(EventDefinitionId, Level);
}
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets,
IReadOnlyList<EventDefinition> matchableEvents,
int year)
{
var idToDef = matchableEvents.Where(e => e.Id != 0).ToDictionary(e => e.Id);
var sb = new StringBuilder();
foreach (var (sheetTitle, _, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
var general = new List<ParsedOccurrenceLine>();
var bySection = new Dictionary<SectionKey, List<ParsedOccurrenceLine>>();
foreach (var line in lines)
{
if (!OccurrenceEventMatcher.TryMatch(line.Name, matchableEvents, out var evt, out var lvl)
|| evt == null
|| !lvl.HasValue)
{
general.Add(line);
continue;
}
var key = new SectionKey(evt.Id, lvl.Value);
if (!bySection.TryGetValue(key, out var list))
{
list = [];
bySection[key] = list;
}
list.Add(line);
}
foreach (var key in bySection.Keys.OrderBy(k => HeaderSortKey(idToDef, k), StringComparer.OrdinalIgnoreCase))
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
continue;
sb.AppendLine($"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}");
foreach (var line in OccurrenceChronologicalSort.Sort(bySection[key], year))
{
var displayName = OccurrenceDisplayNameReducer.ReduceForSection(line.Name, def, key.Level);
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line with { Name = displayName }));
}
}
if (general.Count > 0)
{
sb.AppendLine("General Schedule");
foreach (var line in OccurrenceChronologicalSort.Sort(general, year))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private static string HeaderSortKey(Dictionary<int, EventDefinition> idToDef, SectionKey key)
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
return $"{key.EventDefinitionId} - {key.Level}";
return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}";
}
}
@@ -0,0 +1,13 @@
namespace GoogleSheetsScheduleImport;
public static class ImportLineFormatter
{
public static string FormatOccurrenceLine(ParsedOccurrenceLine line)
{
var name = TextNormalization.ForEmitLine(line.Name);
var time = TextNormalization.ForEmitLine(line.TimeRange);
var loc = TextNormalization.ForEmitLine(line.Location);
var tail = string.IsNullOrEmpty(loc) ? string.Empty : $" {loc}";
return $"{name} {line.Month} {line.Day} {time}{tail}";
}
}
@@ -0,0 +1,26 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
public static class ImportTextEmitter
{
/// <summary>
/// Builds text compatible with <see cref="Core.Parsers.EventOccurrenceParser"/> / Import.razor paste target.
/// </summary>
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets)
{
var sb = new StringBuilder();
foreach (var (sheetTitle, sectionHeader, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
sb.AppendLine(sectionHeader);
foreach (var line in lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,61 @@
using System.Text.Json.Serialization;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// JSON config: sheet tab titles mapped to calendar days plus optional defaults.
/// </summary>
public sealed class MappingConfig
{
/// <summary>Calendar year for occurrence dates (overridden by CLI --year).</summary>
public int? Year { get; set; }
/// <summary>
/// Emitted before occurrence lines for each sheet (unless overridden per sheet).
/// Use "General Schedule" when grid cells are generic schedule items.
/// </summary>
public string? DefaultSectionHeader { get; set; }
/// <summary>Per-sheet overrides and day mapping.</summary>
public List<SheetDayMapping>? Sheets { get; set; }
/// <summary>
/// Event titles (cell text) to treat as site-wide: one output line per date+time, no location.
/// If omitted, built-in rules still include CURFEW.
/// </summary>
public List<string>? SiteWideEventNames { get; set; }
/// <summary>
/// Path to event definitions CSV (column <c>Event</c>), relative to this mapping file or absolute.
/// When set (or when <c>--events-csv</c> is passed), output is grouped into <c>Event - MS/HS</c> sections when possible.
/// </summary>
public string? EventDefinitionsCsv { get; set; }
}
public sealed class SheetDayMapping
{
/// <summary>Exact tab title as it appears in Google Sheets.</summary>
public string Title { get; set; } = "";
/// <summary>Month name matching EventOccurrenceGrammar (e.g. "April").</summary>
public string Month { get; set; } = "";
/// <summary>Day of month (1-31).</summary>
public int Day { get; set; }
/// <summary>Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS").</summary>
public string? SectionHeader { get; set; }
[JsonIgnore]
public string? NormalizedMonth => string.IsNullOrWhiteSpace(Month) ? null : Month.Trim();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Title))
throw new InvalidOperationException("Mapping entry must include a non-empty Title (sheet tab name).");
if (string.IsNullOrWhiteSpace(Month))
throw new InvalidOperationException($"Sheet '{Title}': Month is required.");
if (Day is < 1 or > 31)
throw new InvalidOperationException($"Sheet '{Title}': Day must be between 1 and 31.");
}
}
@@ -0,0 +1,31 @@
using Core.Parsers.EventOccurrence;
using Core.Utility;
namespace GoogleSheetsScheduleImport;
public static class OccurrenceChronologicalSort
{
public static List<ParsedOccurrenceLine> Sort(IEnumerable<ParsedOccurrenceLine> lines, int year)
{
return lines
.OrderBy(l => Key(l, year))
.ThenBy(l => l.SourceRowStart)
.ThenBy(l => l.SourceCol)
.ToList();
}
private static DateTime Key(ParsedOccurrenceLine line, int year)
{
try
{
var d = TextUtil.ParseDate(line.Month, line.Day.ToString(), year);
var timePart = TimeParser.ExtractStartTime(line.TimeRange);
var t = TimeParser.Parse(timePart);
return new DateTime(d, t);
}
catch
{
return DateTime.MaxValue;
}
}
}
@@ -0,0 +1,50 @@
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not
/// <c>MS EventName Activity</c> when the section is already <c>EventName - MS</c>.
/// </summary>
public static class OccurrenceDisplayNameReducer
{
public static string ReduceForSection(string occurrenceName, EventDefinition matched, SchoolLevel level)
{
var (remainder, lvl) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(occurrenceName);
if (!lvl.HasValue || lvl.Value != level)
return TextNormalization.ForEmitLine(occurrenceName);
var r = remainder.Trim();
var eventName = matched.Name.Trim();
if (r.StartsWith(eventName, StringComparison.OrdinalIgnoreCase))
{
r = r[eventName.Length..].TrimStart();
r = TrimLeadingJoiners(r);
}
r = TextNormalization.ForEmitLine(r);
if (string.IsNullOrWhiteSpace(r))
return TextNormalization.ForEmitLine(remainder.Trim());
return r;
}
private static string TrimLeadingJoiners(string s)
{
var r = s;
while (r.Length > 0)
{
var c = r[0];
if (c is '/' or '-' or ':' or '&' or '–' or '—' or ',' or '.')
{
r = r[1..].TrimStart();
continue;
}
break;
}
return r;
}
}
@@ -0,0 +1,71 @@
using Core.Entities;
using Core.Models;
using FuzzySharp;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers.
/// </summary>
public static class OccurrenceEventMatcher
{
private const int MinTokenScore = 62;
public static bool TryMatch(
string occurrenceName,
IReadOnlyList<EventDefinition> events,
out EventDefinition? matched,
out SchoolLevel? level)
{
matched = null;
level = null;
if (string.IsNullOrWhiteSpace(occurrenceName) || events.Count == 0)
return false;
var normalized = TextNormalization.ForEmitLine(occurrenceName);
var (remainder, prefixLevel) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(normalized);
// Section headers require "Event Name - MS|HS". Titles without a clear school prefix stay in General Schedule.
if (!prefixLevel.HasValue)
return false;
var best = FindBest(remainder, events);
if (best == null || string.IsNullOrWhiteSpace(remainder))
return false;
matched = best;
level = prefixLevel;
return true;
}
private static EventDefinition? FindBest(string text, IReadOnlyList<EventDefinition> events)
{
if (string.IsNullOrWhiteSpace(text))
return null;
EventDefinition? best = null;
var bestScore = 0;
foreach (var e in events)
{
if (string.IsNullOrWhiteSpace(e.Name))
continue;
var score = Math.Max(
Fuzz.TokenSetRatio(text, e.Name),
Fuzz.PartialRatio(text, e.Name));
if (score > bestScore)
{
bestScore = score;
best = e;
}
else if (score == bestScore && best != null && e.Name.Length > best.Name.Length)
best = e;
}
if (best == null || bestScore < MinTokenScore)
return null;
return best;
}
}
@@ -0,0 +1,14 @@
using Core.Entities;
using Core.Models;
using Core.Services;
namespace GoogleSheetsScheduleImport;
public static class ParserRoundTripValidator
{
public static EventOccurrenceParseResult Validate(string text, ICollection<EventDefinition> events)
{
var parser = new EventOccurrenceParserService(null);
return parser.ParseFromText(text, events);
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Linq;
using System.Text.Json;
using Core.Entities;
using Core.Parsers;
using Google.Apis.Sheets.v4.Data;
using GoogleSheetsScheduleImport;
static void PrintUsage()
{
Console.Error.WriteLine("""
Google Sheets -> Event occurrence import text (for /calendar/event-occurrences/import)
Required:
--sheet-url <url-or-id> Google Sheet URL or raw spreadsheet id
--mapping <path.json> Tab titles -> month/day (+ optional section headers)
--output <path.txt> Output file path
Optional:
--year <yyyy> Calendar year (overrides mapping file)
--api-key <key> Google API key (else env GOOGLE_SHEETS_API_KEY)
--tabs <a,b> Only process these tab titles (exact match)
--events-csv <path> Event definitions CSV with 'Event' column (grouping + validation)
--no-group-by-event Keep a single General Schedule block per sheet (no MS/HS sections)
--strict Exit code 1 if parser reports errors or zero occurrences
Environment:
GOOGLE_SHEETS_API_KEY Default API key for Sheets API (spreadsheet must be accessible to the key)
""");
}
try
{
var argsDict = ParseArgs(args);
if (argsDict.ContainsKey("help") || argsDict.ContainsKey("h"))
{
PrintUsage();
return 0;
}
var sheetUrl = GetRequired(argsDict, "sheet-url");
var mappingPath = GetRequired(argsDict, "mapping");
var outputPath = GetRequired(argsDict, "output");
var mappingJson = await File.ReadAllTextAsync(mappingPath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip };
var mapping = JsonSerializer.Deserialize<MappingConfig>(mappingJson, options)
?? throw new InvalidOperationException("Mapping file is empty or invalid JSON.");
var year = int.TryParse(argsDict.GetValueOrDefault("year"), out var y) ? y
: mapping.Year ?? DateTime.Now.Year;
var apiKey = argsDict.GetValueOrDefault("api-key")
?? Environment.GetEnvironmentVariable("GOOGLE_SHEETS_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Missing API key: pass --api-key or set GOOGLE_SHEETS_API_KEY.");
return 2;
}
HashSet<string>? tabFilter = null;
if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg))
{
tabFilter = new HashSet<string>(
tabsArg.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
StringComparer.Ordinal);
}
var spreadsheetId = SpreadsheetId.FromUrlOrId(sheetUrl);
var reader = new GoogleSheetGridReader(apiKey);
var spreadsheet = reader.FetchSpreadsheet(spreadsheetId);
var sheetMappings = mapping.Sheets ?? [];
if (sheetMappings.Count == 0)
throw new InvalidOperationException("Mapping file must include a non-empty \"sheets\" array with tab titles and dates.");
foreach (var sm in sheetMappings)
sm.Validate();
var defaultSection = mapping.DefaultSectionHeader?.Trim();
if (string.IsNullOrEmpty(defaultSection))
defaultSection = "General Schedule";
var mappingDir = Path.GetDirectoryName(Path.GetFullPath(mappingPath)) ?? Directory.GetCurrentDirectory();
var eventsCsvArg = argsDict.GetValueOrDefault("events-csv");
var eventsCsvConfigured = mapping.EventDefinitionsCsv;
var eventsCsvPath = !string.IsNullOrWhiteSpace(eventsCsvArg)
? (Path.IsPathRooted(eventsCsvArg) ? eventsCsvArg : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), eventsCsvArg)))
: (!string.IsNullOrWhiteSpace(eventsCsvConfigured)
? Path.GetFullPath(Path.Combine(mappingDir, eventsCsvConfigured!))
: null);
List<EventDefinition> csvEvents = new();
if (!string.IsNullOrWhiteSpace(eventsCsvPath) && File.Exists(eventsCsvPath))
csvEvents = EventDefinitionsCsvLoader.Load(eventsCsvPath);
else if (!string.IsNullOrWhiteSpace(eventsCsvPath))
throw new InvalidOperationException($"Event definitions CSV not found: {eventsCsvPath}");
var groupByEvent = csvEvents.Count > 0 && !argsDict.ContainsKey("no-group-by-event");
List<EventDefinition> validationEvents = MergeValidationEvents(csvEvents);
var warnings = new List<string>();
var sheetOutputs = new List<(string Title, string SectionHeader, List<ParsedOccurrenceLine> Lines)>();
foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty<Sheet>())
{
var title = sheet.Properties?.Title ?? "";
if (tabFilter != null && !tabFilter.Contains(title))
continue;
var dayMap = sheetMappings.FirstOrDefault(m =>
m.Title.Equals(title, StringComparison.Ordinal));
if (dayMap == null)
{
warnings.Add($"Skipping sheet '{title}': no entry in mapping JSON.");
continue;
}
var month = dayMap.NormalizedMonth!;
if (!EventOccurrenceGrammar.MonthNames.Any(m => m.Equals(month, StringComparison.OrdinalIgnoreCase)))
warnings.Add($"Sheet '{title}': month '{month}' is not a standard grammar month name.");
try
{
Core.Utility.TextUtil.ParseDate(month, dayMap.Day.ToString(), year);
}
catch (Exception ex)
{
warnings.Add($"Sheet '{title}': invalid date {month} {dayMap.Day}, {year}: {ex.Message}");
}
var grid = GoogleSheetGridReader.BuildModel(sheet);
var lines = ScheduleGridExtractor.Extract(grid, month, dayMap.Day, warnings);
lines = GlobalEventDeduplicator.Deduplicate(lines, mapping.SiteWideEventNames);
var section = string.IsNullOrWhiteSpace(dayMap.SectionHeader) ? defaultSection : dayMap.SectionHeader!.Trim();
sheetOutputs.Add((title, section, lines));
}
foreach (var sm in sheetMappings)
{
var exists = spreadsheet.Sheets?.Any(s => string.Equals(s.Properties?.Title, sm.Title, StringComparison.Ordinal)) ?? false;
if (!exists)
warnings.Add($"Mapping references sheet '{sm.Title}' but it was not found in the spreadsheet.");
}
var text = groupByEvent
? GroupedImportTextEmitter.Build(sheetOutputs, csvEvents, year)
: ImportTextEmitter.Build(sheetOutputs);
var outDir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
await File.WriteAllTextAsync(outputPath, text, System.Text.Encoding.UTF8);
Console.WriteLine($"Wrote {outputPath} ({text.Length} characters).");
foreach (var w in warnings)
Console.WriteLine($"WARNING: {w}");
var strict = argsDict.ContainsKey("strict");
var parseResult = ParserRoundTripValidator.Validate(text, validationEvents);
Console.WriteLine($"Parser round-trip: success={parseResult.IsSuccess}, occurrences={parseResult.TotalParsed}, issues={parseResult.Issues.Count}, errors={parseResult.Errors.Count}");
foreach (var err in parseResult.Errors)
Console.WriteLine($" ERROR: {err}");
foreach (var issue in parseResult.Issues.Take(20))
Console.WriteLine($" Issue L{issue.LineNumber}: {issue.Message}");
if (parseResult.Issues.Count > 20)
Console.WriteLine($" ... and {parseResult.Issues.Count - 20} more issues.");
if (strict && (!parseResult.IsSuccess || parseResult.TotalParsed == 0))
{
Console.Error.WriteLine("Strict mode: failing due to parser errors or zero occurrences parsed.");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Console.Error.WriteLine(ex.ToString());
return 1;
}
static Dictionary<string, string> ParseArgs(string[] args)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var a = args[i];
if (!a.StartsWith("--", StringComparison.Ordinal))
continue;
var key = a[2..];
if (string.IsNullOrEmpty(key))
continue;
if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
{
d[key] = args[i + 1];
i++;
}
else
d[key] = "true";
}
return d;
}
static string GetRequired(Dictionary<string, string> d, string key)
{
if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v))
throw new InvalidOperationException($"Missing required --{key}");
return v;
}
static List<EventDefinition> MergeValidationEvents(List<EventDefinition> fromCsv)
{
if (fromCsv.Count == 0)
{
return
[
EventDefinition.GeneralSchedule,
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
];
}
var list = new List<EventDefinition>(fromCsv);
foreach (var extra in new EventDefinition[]
{
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
})
{
if (list.All(e => !string.Equals(e.Name, extra.Name, StringComparison.OrdinalIgnoreCase)))
list.Add(extra);
}
return list;
}
@@ -0,0 +1,149 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Interprets location headers + time rows + colored/value blocks as occurrence lines.
/// </summary>
public static class ScheduleGridExtractor
{
public static List<ParsedOccurrenceLine> Extract(
GridSheetModel grid,
string month,
int day,
List<string> warnings)
{
var result = new List<ParsedOccurrenceLine>();
if (grid.RowCount < 2 || grid.ColCount < 2)
{
warnings.Add($"Sheet '{grid.SheetTitle}': grid too small; need at least a header row and time column.");
return result;
}
var locations = new string[grid.ColCount];
for (var c = 1; c < grid.ColCount; c++)
{
var header = grid.Values[0][c];
locations[c] = string.IsNullOrWhiteSpace(header) ? $"Column {c + 1}" : header.Trim();
}
var rowTimes = new TimeOnly?[grid.RowCount];
rowTimes[0] = null;
for (var r = 1; r < grid.RowCount; r++)
{
var cell = grid.Values[r][0];
if (!TimeCellParser.TryParse(cell, out var t))
{
if (!string.IsNullOrWhiteSpace(cell))
warnings.Add($"Sheet '{grid.SheetTitle}' row {r + 1}: could not parse time label '{cell}'.");
rowTimes[r] = null;
}
else
rowTimes[r] = t;
}
var slot = InferSlotDuration(rowTimes, warnings, grid.SheetTitle);
for (var c = 1; c < grid.ColCount; c++)
{
var location = locations[c];
var r = 1;
while (r < grid.RowCount)
{
if (!IsOccupied(grid, r, c))
{
r++;
continue;
}
var startRow = r;
var name = grid.Values[r][c] ?? "";
var key = BlockKey(grid, r, c);
var endRow = r;
while (endRow + 1 < grid.RowCount &&
IsOccupied(grid, endRow + 1, c) &&
BlockKey(grid, endRow + 1, c) == key)
{
endRow++;
}
var startTime = rowTimes[startRow];
if (startTime == null)
{
warnings.Add(
$"Sheet '{grid.SheetTitle}' ({location}): block at row {startRow + 1} has no parseable start time in column A.");
r = endRow + 1;
continue;
}
var endInstant = ComputeEndTime(rowTimes, endRow, grid.RowCount, slot);
var timeRange = TimeFormatter.ToParserTimeRange(startTime.Value, endInstant);
var title = name.Trim();
if (string.IsNullOrEmpty(title))
title = "(no title)";
result.Add(new ParsedOccurrenceLine(
Name: title,
Month: month,
Day: day,
TimeRange: timeRange,
Location: location,
SourceRowStart: startRow,
SourceRowEnd: endRow,
SourceCol: c));
r = endRow + 1;
}
}
return result;
}
private static bool IsOccupied(GridSheetModel grid, int r, int c)
{
var v = grid.Values[r][c];
var bg = grid.BackgroundKeys?[r][c];
if (!string.IsNullOrWhiteSpace(v))
return true;
return bg != null; // colored empty cell
}
private static (string NameKey, string? Bg) BlockKey(GridSheetModel grid, int r, int c)
{
var raw = grid.Values[r][c] ?? "";
var v = raw.Trim();
var bg = grid.BackgroundKeys?[r][c];
return (v, bg);
}
private static TimeOnly ComputeEndTime(TimeOnly?[] rowTimes, int endRow, int rowCount, TimeSpan slot)
{
if (endRow + 1 < rowCount && rowTimes[endRow + 1] != null)
return rowTimes[endRow + 1]!.Value;
var lastStart = rowTimes[endRow] ?? throw new InvalidOperationException();
return lastStart.Add(slot);
}
private static TimeSpan InferSlotDuration(TimeOnly?[] rowTimes, List<string> warnings, string sheetTitle)
{
var deltas = new List<int>();
for (var i = 1; i < rowTimes.Length - 1; i++)
{
if (rowTimes[i] == null || rowTimes[i + 1] == null)
continue;
var minutes = (int)(rowTimes[i + 1]!.Value - rowTimes[i]!.Value).TotalMinutes;
if (minutes > 0 && minutes <= 24 * 60)
deltas.Add(minutes);
}
if (deltas.Count == 0)
{
warnings.Add($"Sheet '{sheetTitle}': could not infer time-slot length from column A; defaulting to 30 minutes.");
return TimeSpan.FromMinutes(30);
}
var g = deltas.GroupBy(d => d).OrderByDescending(g => g.Count()).First();
return TimeSpan.FromMinutes(g.Key);
}
}
@@ -0,0 +1,40 @@
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to <see cref="Core.Entities.EventDefinition.Name"/>.
/// </summary>
public static class SchoolLevelPrefixParser
{
/// <returns>Remainder text and school level when unambiguous; <c>null</c> level for MS/HS combined or unknown.</returns>
public static (string Remainder, SchoolLevel? Level) StripLeadingSchoolPrefix(string raw)
{
var s = TextNormalization.ForEmitLine(raw);
if (string.IsNullOrEmpty(s))
return (s, null);
if (s.StartsWith("MS/HS", StringComparison.OrdinalIgnoreCase))
{
var rest = s[5..].TrimStart();
if (rest.StartsWith('/'))
rest = rest[1..].TrimStart();
return (rest, null);
}
if (s.Length >= 3 && s.StartsWith("MS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
if (s.Length >= 3 && s.StartsWith("MS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
return (s, null);
}
public static string ToSectionSuffix(SchoolLevel level) =>
level == SchoolLevel.MiddleSchool ? "MS" : "HS";
}
@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace GoogleSheetsScheduleImport;
public static class SpreadsheetId
{
private static readonly Regex IdRegex = new(
@"/spreadsheets/d/([a-zA-Z0-9-_]+)",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id.
/// </summary>
public static string FromUrlOrId(string input)
{
var trimmed = input.Trim();
var m = IdRegex.Match(trimmed);
if (m.Success)
return m.Groups[1].Value;
if (trimmed.Length > 20 && !trimmed.Contains('/') && !trimmed.Contains(':'))
return trimmed;
throw new ArgumentException(
"Expected a Google Sheets URL (…/spreadsheets/d/{id}/…) or a raw spreadsheet id.", nameof(input));
}
}
@@ -0,0 +1,44 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators.
/// Import text must be one logical line per occurrence.
/// </summary>
public static class TextNormalization
{
public static string ForSheetCell(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
public static string ForEmitLine(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
private static string CollapseWhitespace(string s)
{
var sb = new StringBuilder(s.Length);
var pendingSpace = false;
foreach (var ch in s)
{
if (char.IsWhiteSpace(ch))
pendingSpace = true;
else
{
if (pendingSpace && sb.Length > 0)
sb.Append(' ');
pendingSpace = false;
sb.Append(ch);
}
}
return sb.ToString().Trim();
}
}
@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Core.Parsers.EventOccurrence;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Parses time labels from the first column of schedule grids.
/// </summary>
public static class TimeCellParser
{
private static readonly Regex ClockRegex = new(
@"^(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ap>a\.?m\.?|p\.?m\.?|AM|PM|am|pm)\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool TryParse(string? cellText, out TimeOnly time)
{
time = default;
if (string.IsNullOrWhiteSpace(cellText))
return false;
var t = cellText.Trim();
if (t.Equals("NOON", StringComparison.OrdinalIgnoreCase))
{
time = new TimeOnly(12, 0);
return true;
}
var m = ClockRegex.Match(t);
if (m.Success)
{
var h = int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture);
var minute = m.Groups["m"].Success ? int.Parse(m.Groups["m"].Value, CultureInfo.InvariantCulture) : 0;
var apStr = m.Groups["ap"].Value;
var isPm = apStr.Contains('P', StringComparison.OrdinalIgnoreCase);
var isAm = apStr.Contains('A', StringComparison.OrdinalIgnoreCase);
if (isPm && h < 12) h += 12;
if (isAm && h == 12) h = 0;
time = new TimeOnly(h, minute);
return true;
}
// Fallback: use core TimeParser if string already looks like parsed format
try
{
time = TimeParser.Parse(t);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,26 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns).
/// </summary>
public static class TimeFormatter
{
public static string ToParserTimeString(TimeOnly time)
{
var h12 = time.Hour % 12;
if (h12 == 0) h12 = 12;
var minute = time.Minute;
var isPm = time.Hour >= 12;
var ap = isPm ? "p.m." : "a.m.";
if (minute == 0)
return $"{h12} {ap}";
return $"{h12}:{minute:D2} {ap}";
}
public static string ToParserTimeRange(TimeOnly start, TimeOnly end)
{
if (start == end)
return ToParserTimeString(start);
return $"{ToParserTimeString(start)} - {ToParserTimeString(end)}";
}
}