Files
chapter-organizer/tools/GoogleSheetsScheduleImport/Program.cs
T
poprhythm f400813667 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.
2026-04-04 21:55:44 -04:00

243 lines
9.4 KiB
C#

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;
}