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 Google Sheet URL or raw spreadsheet id --mapping Tab titles -> month/day (+ optional section headers) --output Output file path Optional: --year Calendar year (overrides mapping file) --api-key Google API key (else env GOOGLE_SHEETS_API_KEY) --tabs Only process these tab titles (exact match) --events-csv 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(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? tabFilter = null; if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg)) { tabFilter = new HashSet( 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 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 validationEvents = MergeValidationEvents(csvEvents); var warnings = new List(); var sheetOutputs = new List<(string Title, string SectionHeader, List Lines)>(); foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty()) { 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 ParseArgs(string[] args) { var d = new Dictionary(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 d, string key) { if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v)) throw new InvalidOperationException($"Missing required --{key}"); return v; } static List MergeValidationEvents(List fromCsv) { if (fromCsv.Count == 0) { return [ EventDefinition.GeneralSchedule, EventDefinition.MeetTheCandidates, EventDefinition.ChapterOfficerMeeting, EventDefinition.VotingDelegateMeeting, EventDefinition.SocialGathering ]; } var list = new List(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; }