diff --git a/Core/Models/EventOccurrenceParseGroup.cs b/Core/Models/EventOccurrenceParseGroup.cs new file mode 100644 index 0000000..1c8c694 --- /dev/null +++ b/Core/Models/EventOccurrenceParseGroup.cs @@ -0,0 +1,10 @@ +using Core.Entities; + +namespace Core.Models; + +/// +/// Groups parsed occurrences by event definition and optional section school level from headers +/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same +/// can appear in multiple groups. +/// +public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel); diff --git a/Core/Models/EventOccurrenceParseResult.cs b/Core/Models/EventOccurrenceParseResult.cs index a83b758..2f9a6f6 100644 --- a/Core/Models/EventOccurrenceParseResult.cs +++ b/Core/Models/EventOccurrenceParseResult.cs @@ -9,11 +9,11 @@ namespace Core.Models; public class EventOccurrenceParseResult { /// - /// Dictionary of parsed event occurrences, keyed by EventDefinition. - /// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering), - /// the EventDefinition key will be the static instance. + /// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers. + /// Special events use static instances with + /// typically null. /// - public IDictionary> Occurrences { get; set; } = new Dictionary>(); + public IDictionary> Occurrences { get; set; } = new Dictionary>(); /// /// List of parsing errors (critical issues that prevented parsing). diff --git a/Core/Parsers/EventOccurrenceParser.cs b/Core/Parsers/EventOccurrenceParser.cs index 6184c5a..b115c6e 100644 --- a/Core/Parsers/EventOccurrenceParser.cs +++ b/Core/Parsers/EventOccurrenceParser.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Core.Entities; using Core.Models; using EventOccurrenceParsers = Core.Parsers.EventOccurrence; @@ -12,7 +12,7 @@ namespace Core.Parsers; /// public class EventOccurrenceParserResult { - public IDictionary> Occurrences { get; set; } = new Dictionary>(); + public IDictionary> Occurrences { get; set; } = new Dictionary>(); public List Issues { get; set; } = new(); public List SkippedSectionHeaders { get; set; } = new(); public int SkippedEventCount { get; set; } @@ -296,12 +296,14 @@ public class EventOccurrenceParser Location = location }; - if (!occurrences.ContainsKey(eventDefinition)) - occurrences.Add(eventDefinition, []); - occurrences[eventDefinition].Add(eventOccurrence); - - // Reset section level when we successfully parse an occurrence (means we're in a valid section) - currentSectionLevel = null; + var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel); + if (!occurrences.TryGetValue(groupKey, out var groupList)) + { + groupList = []; + occurrences[groupKey] = groupList; + } + + groupList.Add(eventOccurrence); } return result; diff --git a/Core/Services/EventOccurrenceParserService.cs b/Core/Services/EventOccurrenceParserService.cs index 7e070e8..dfe1b28 100644 --- a/Core/Services/EventOccurrenceParserService.cs +++ b/Core/Services/EventOccurrenceParserService.cs @@ -65,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService // Convert parsed occurrences to result format, handling special event types foreach (var kvp in parsedOccurrences) { - var eventDefinition = kvp.Key; + var group = kvp.Key; + var eventDefinition = group.EventDefinition; var occurrences = kvp.Value; // Check if this is a special event type (not stored in database) @@ -90,8 +91,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService }; } - // Add to result with the special EventDefinition as key - result.Occurrences[eventDefinition] = occurrences; + result.Occurrences[group] = occurrences; } else { @@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService occurrence.SpecialEventType = null; } - result.Occurrences[eventDefinition] = occurrences; + result.Occurrences[group] = occurrences; } } diff --git a/TSA Chapter Organizer.sln b/TSA Chapter Organizer.sln index 8c305fe..a1585d8 100644 --- a/TSA Chapter Organizer.sln +++ b/TSA Chapter Organizer.sln @@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoogleSheetsScheduleImport", "tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj", "{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}" ProjectSection(ProjectDependencies) = postProject {338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF} @@ -35,6 +37,10 @@ Global {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU + {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs b/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs new file mode 100644 index 0000000..7e2f5ca --- /dev/null +++ b/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs @@ -0,0 +1,28 @@ +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class GlobalEventDeduplicatorTests +{ + [Test] + public void Curfew_same_time_multiple_locations_becomes_one_line_without_location() + { + var lines = new List + { + new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1), + new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2), + new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1) + }; + + var result = GlobalEventDeduplicator.Deduplicate(lines); + + Assert.Multiple(() => + { + Assert.That(result.Count, Is.EqualTo(2)); + var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase)); + Assert.That(curfew.Location, Is.Empty); + Assert.That(result.Any(l => l.Name == "Meeting"), Is.True); + }); + } +} diff --git a/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs b/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs new file mode 100644 index 0000000..bac095f --- /dev/null +++ b/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs @@ -0,0 +1,40 @@ +using Core.Entities; +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class ImportTextEmitterRoundTripTests +{ + [Test] + public void EmittedText_Parses_UnderGeneralSchedule() + { + var sheets = new List<(string SheetTitle, string SectionHeader, List Lines)> + { + ("Thursday", "General Schedule", new List + { + new( + Name: "Opening Ceremony", + Month: "April", + Day: 3, + TimeRange: "9 a.m. - 10 a.m.", + Location: "Main Hall", + SourceRowStart: 1, + SourceRowEnd: 1, + SourceCol: 1) + }) + }; + + var text = ImportTextEmitter.Build(sheets); + var result = ParserRoundTripValidator.Validate(text, new List + { + EventDefinition.GeneralSchedule + }); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors)); + Assert.That(result.TotalParsed, Is.EqualTo(1)); + }); + } +} diff --git a/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs b/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs new file mode 100644 index 0000000..23581c9 --- /dev/null +++ b/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs @@ -0,0 +1,29 @@ +using Core.Entities; +using Core.Models; +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class OccurrenceDisplayNameReducerTests +{ + private static EventDefinition Cyber() => + new() + { + Id = 1, + Name = "Cybersecurity", + ShortName = "Cyber", + Eligibility = "", + EventFormat = EventFormat.Team + }; + + [Test] + public void Strips_ms_prefix_and_event_name() + { + var n = OccurrenceDisplayNameReducer.ReduceForSection( + "MS Cybersecurity Semifinals Presentations", + Cyber(), + SchoolLevel.MiddleSchool); + Assert.That(n, Is.EqualTo("Semifinals Presentations")); + } +} diff --git a/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs b/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs new file mode 100644 index 0000000..2a0b371 --- /dev/null +++ b/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs @@ -0,0 +1,39 @@ +using Core.Entities; +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class OccurrenceEventMatcherTests +{ + private static EventDefinition E(string name, int id) => + new() + { + Id = id, + Name = name, + ShortName = name, + Eligibility = "", + EventFormat = EventFormat.Team + }; + + [Test] + public void Ms_prefix_matches_Biotechnology() + { + var events = new List { E("Biotechnology", 1), E("Biotechnology Design", 2) }; + var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl); + Assert.Multiple(() => + { + Assert.That(ok, Is.True); + Assert.That(evt!.Name, Is.EqualTo("Biotechnology")); + Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool)); + }); + } + + [Test] + public void No_Clear_prefix_goes_unmatched_or_general_bucket() + { + var events = new List { E("Opening Session", 1) }; + var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl); + Assert.That(ok, Is.False); + } +} diff --git a/Tests/GoogleSheets/ScheduleGridExtractorTests.cs b/Tests/GoogleSheets/ScheduleGridExtractorTests.cs new file mode 100644 index 0000000..347dfe8 --- /dev/null +++ b/Tests/GoogleSheets/ScheduleGridExtractorTests.cs @@ -0,0 +1,42 @@ +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class ScheduleGridExtractorTests +{ + [Test] + public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot() + { + string?[][] values = + [ + [null, "Main Hall"], + ["9:00 a.m.", "Opening Ceremony"], + ["10:00 a.m.", null] + ]; + string?[][] bg = + [ + [null, null], + [null, null], + [null, null] + ]; + var grid = new GridSheetModel + { + SheetTitle = "Day1", + Values = values, + BackgroundKeys = bg + }; + var warnings = new List(); + var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings); + + Assert.Multiple(() => + { + Assert.That(lines, Has.Count.EqualTo(1)); + Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony")); + Assert.That(lines[0].Month, Is.EqualTo("April")); + Assert.That(lines[0].Day, Is.EqualTo(2)); + Assert.That(lines[0].Location, Is.EqualTo("Main Hall")); + Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m.")); + }); + } +} diff --git a/Tests/GoogleSheets/TextNormalizationTests.cs b/Tests/GoogleSheets/TextNormalizationTests.cs new file mode 100644 index 0000000..b65db35 --- /dev/null +++ b/Tests/GoogleSheets/TextNormalizationTests.cs @@ -0,0 +1,14 @@ +using GoogleSheetsScheduleImport; + +namespace Tests.GoogleSheets; + +[TestFixture] +public class TextNormalizationTests +{ + [Test] + public void Collapses_line_separator_and_newlines() + { + var s = "Banquet Room\u2028E"; + Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E")); + } +} diff --git a/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs b/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs index 1f0d563..e0d8434 100644 --- a/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs +++ b/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs @@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests // Verify successful occurrence is still parsed (if any valid lines exist) // The "Valid Event" line should parse successfully despite other issues var validEvent = events.First(e => e.Name == "Valid Event"); - if (result.Occurrences.ContainsKey(validEvent)) + var validGroup = new EventOccurrenceParseGroup(validEvent, null); + if (result.Occurrences.ContainsKey(validGroup)) { - Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1)); + Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1)); } // Note: It's acceptable if the valid event doesn't parse if there are critical issues, // but typically it should still parse since it's a valid line @@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests // Verify occurrences were parsed correctly (if they were parsed) var testEvent = events.First(e => e.Name == "Test Event"); - if (result.Occurrences.ContainsKey(testEvent)) + var testGroup = new EventOccurrenceParseGroup(testEvent, null); + if (result.Occurrences.ContainsKey(testGroup)) { - Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1)); + Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1)); - var occurrence = result.Occurrences[testEvent].First(); + var occurrence = result.Occurrences[testGroup].First(); Assert.That(occurrence.Name, Is.EqualTo("Test Event")); Assert.That(occurrence.Location, Is.EqualTo("Room 101")); } @@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests // The important thing is that the parser doesn't crash and processes the input // Verify locations are extracted correctly (pattern matching is no longer used) - var testEventOccurrence = result.Occurrences.ContainsKey(testEvent) - ? result.Occurrences[testEvent].FirstOrDefault() + var testEventOccurrence = result.Occurrences.ContainsKey(testGroup) + ? result.Occurrences[testGroup].FirstOrDefault() : null; if (testEventOccurrence != null) { @@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests // Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14") // General Schedule section uses EventDefinition.GeneralSchedule - Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule), - $"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}"); + var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null); + Assert.That(result.Occurrences, Does.ContainKey(gsGroup), + $"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}"); - var occurrences = result.Occurrences[EventDefinition.GeneralSchedule]; + var occurrences = result.Occurrences[gsGroup]; Assert.That(occurrences, Has.Count.GreaterThan(0), "Should have at least one occurrence in General Schedule"); @@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests // Assert Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); - Assert.That(result.Occurrences.ContainsKey(events[0])); + Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool))); } finally { @@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests // Assert Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); - Assert.That(result.Occurrences.ContainsKey(events[0])); + Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool))); } finally { @@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests // Assert Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); - Assert.That(result.Occurrences.ContainsKey(events[0])); + Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool))); } finally { @@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests // Assert Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); - Assert.That(result.Occurrences.ContainsKey(events[0])); + Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool))); } finally { diff --git a/Tests/Parsers/EventOccurrenceParser_Tests.cs b/Tests/Parsers/EventOccurrenceParser_Tests.cs index a524418..2c7d122 100644 --- a/Tests/Parsers/EventOccurrenceParser_Tests.cs +++ b/Tests/Parsers/EventOccurrenceParser_Tests.cs @@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests /// /// Writes special events summary to console. /// - private static void WriteSpecialEventsSummary(IDictionary> occurrences) + private static void WriteSpecialEventsSummary(IDictionary> occurrences) { Console.WriteLine($"\n--- Special Events Found ---"); - if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs)) - Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences"); - if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc)) - Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences"); - if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com)) - Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences"); - if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm)) - Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences"); + static int CountFor(IDictionary> occ, EventDefinition def) => + occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count); + + var gs = CountFor(occurrences, EventDefinition.GeneralSchedule); + if (gs > 0) + Console.WriteLine($" GeneralSchedule: {gs} occurrences"); + var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates); + if (mtc > 0) + Console.WriteLine($" MeetTheCandidates: {mtc} occurrences"); + var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting); + if (com > 0) + Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences"); + var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting); + if (vdm > 0) + Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences"); } /// @@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests /// /// Writes special events to console output. /// - private static void WriteSpecialEvents(IDictionary> occurrences) + private static void WriteSpecialEvents(IDictionary> occurrences) { + static List ListFor(IDictionary> occ, EventDefinition def) => + occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList(); + Console.WriteLine("General Schedule"); - if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule)) - { - foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime)) - { - Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); - } - } + foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime)) + Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine("Meet the Candidates"); - if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates)) - { - foreach (var eo in meetTheCandidates) - { - Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); - } - } + foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates)) + Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine("Chapter Officer Meeting"); - if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting)) - { - foreach (var eo in chapterOfficerMeeting) - { - Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); - } - } + foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting)) + Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine("Voting Delegate Meeting"); - if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting)) - { - foreach (var eo in votingDelegateMeeting) - { - Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); - } - } + foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting)) + Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); } #endregion @@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests { Console.WriteLine($"{@event.Name}"); - if (!dictionary.TryGetValue(@event, out var eventOccurrences)) + var eventOccurrences = dictionary + .Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event)) + .SelectMany(kvp => kvp.Value) + .ToList(); + if (eventOccurrences.Count == 0) { Console.WriteLine($"!!! eventDefinition not found {@event.Name}"); continue; @@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests { Console.WriteLine($"{@event.Name}"); - if (!dictionary.TryGetValue(@event, out var eventOccurrences)) + var eventOccurrences = dictionary + .Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event)) + .SelectMany(kvp => kvp.Value) + .ToList(); + if (eventOccurrences.Count == 0) { Console.WriteLine($"!!! eventDefinition not found {@event.Name}"); continue; @@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests // Total expected MS occurrences: 16 var msEventCount = 0; - if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences)) + if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences)) msEventCount += csOccurrences.Count; - if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences)) + if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences)) msEventCount += codingOccurrences.Count; - if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences)) + if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences)) msEventCount += csvOccurrences.Count; - if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences)) + if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences)) msEventCount += ccOccurrences.Count; // When no school level is set, HS events should be processed (not skipped) @@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format"); // Verify specific locations are parsed - if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences)) + if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences)) { var locations = childrensStoriesOccurrences .Select(eo => eo.Location) @@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests "HS section header should NOT be in SkippedSectionHeaders when no school level is set"); // With no school level filtering, both MS and HS events are processed - if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences)) - { - // With no school level set, we process all occurrences (both MS and HS) - // Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total - Assert.That(allOccurrences, Has.Count.EqualTo(5), - "Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " + - $"Found {allOccurrences.Count} occurrences total."); - - // Verify all expected occurrence names are present - var occurrenceNames = allOccurrences.Select(o => o.Name).ToList(); - Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences"); - Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences"); - Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence"); - } + result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences); + result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences); + msOccurrences ??= []; + hsOccurrences ??= []; + Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences"); + Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences"); + + var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList(); + Assert.That(allNames, Does.Contain("Submit Entry")); + Assert.That(allNames, Does.Contain("Judging")); + Assert.That(allNames, Does.Contain("Pick-up")); Assert.Pass("All events processed when no school level is set"); } @@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile); } } + + [Test] + public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups() + { + var testContent = "Prepared Speech - HS\n" + + "Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" + + "Prepared Speech - MS\n" + + "Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10"; + var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent); + var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") }; + var parser = new EventOccurrenceParser(tempFile, events); + + try + { + var result = parser.Parse(); + Assert.That(result.Issues, Has.Count.EqualTo(0)); + + var def = events[0]; + Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True); + Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True); + Assert.That(hsList, Has.Count.EqualTo(1)); + Assert.That(msList, Has.Count.EqualTo(1)); + Assert.That(hsList![0].Name, Does.Contain("Extemporaneous")); + Assert.That(msList![0].Name, Does.Contain("Prelims")); + } + finally + { + EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile); + } + } } \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 705cb6a..3e08bb4 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 enable @@ -18,6 +18,7 @@ + diff --git a/WebApp/Components/Features/Calendar/Import.razor b/WebApp/Components/Features/Calendar/Import.razor index 9163457..7f5873a 100644 --- a/WebApp/Components/Features/Calendar/Import.razor +++ b/WebApp/Components/Features/Calendar/Import.razor @@ -121,7 +121,7 @@ @if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0) { - Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s) + Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s) @if (_parseResult.SkippedEventCount > 0) { (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level) @@ -201,9 +201,11 @@ { Occurrences by Event: - @foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key))) + @foreach (var kvp in _parseResult.Occurrences + .OrderBy(x => GetEventName(x.Key.EventDefinition)) + .ThenBy(x => x.Key.SectionSchoolLevel switch { SchoolLevel.MiddleSchool => 0, SchoolLevel.HighSchool => 1, _ => 2 })) { - + Name @@ -395,6 +397,17 @@ return eventDefinition.Name; } + private string GetOccurrenceGroupTitle(EventOccurrenceParseGroup group) + { + var title = GetEventName(group.EventDefinition); + return group.SectionSchoolLevel switch + { + SchoolLevel.MiddleSchool => $"{title} (MS)", + SchoolLevel.HighSchool => $"{title} (HS)", + _ => title + }; + } + private Color GetIssueTypeColor(ParsingIssueType issueType) { return issueType switch diff --git a/docs/instructions/google-sheets-schedule-import.md b/docs/instructions/google-sheets-schedule-import.md new file mode 100644 index 0000000..24eb48f --- /dev/null +++ b/docs/instructions/google-sheets-schedule-import.md @@ -0,0 +1,78 @@ +--- +created: 2026-03-30 +description: Extract a multi-day Google Sheets schedule grid into event-occurrence import text for the web app. +--- + +# Import event schedule from Google Sheets + +This repo includes a console tool that reads a **public** Google Sheet (with grid data and cell formatting), interprets each tab as one calendar day, and writes **plain text** in the same format as the legacy PDF-derived files consumed by **Calendar → Import Event Occurrences** (`/calendar/event-occurrences/import`). + +## Prerequisites + +1. **Google Cloud API key** with the **Google Sheets API** enabled. +2. The spreadsheet must be readable with that key (typically **File → Share → Anyone with the link** *Viewer*, or share explicitly as needed for your key type). +3. A **mapping JSON** file that lists each tab title and the calendar **month/day** for that tab (see sample under `docs/notes/`). + +## Expected grid layout + +- **Row 1:** Column `B` onward = **location** names (column `A` may be blank or a label). +- **Column A (from row 2 down):** **Start time** for each row (e.g. `9:00 AM`, `9:00 a.m.`). The tool infers **slot length** from consecutive times and uses the next row’s time as the **end** of a block when cells span multiple rows. +- **Data cells:** Event title (required for a block) and/or **non-white background** (treated as part of the same block as adjacent cells with the same text + color). **Merged cells** are expanded so the anchor value applies to the whole merge. + +## Default section header + +Output uses **`General Schedule`** as the section header unless you override it in mapping. That matches grids where each cell is its own schedule item (conference-style). If every line should belong to a specific competition event, set `sectionHeader` for that tab to something like `Biotechnology - MS` (must fuzzy-match an event in your database when importing). + +Under **General Schedule**, the parser assigns occurrences to the **General Schedule** event type in the app (see `EventDefinitionResolver` / `EventDefinition.GeneralSchedule`). + +## Grouping into competition events (PDF-style sections) + +If you set **`eventDefinitionsCsv`** in the mapping JSON (or pass **`--events-csv`**), the tool loads event names from the CSV **`Event`** column and, when possible, rewrites output like the state schedule text: + +- Section header: **`{Event Name} - MS`** or **`{Event Name} - HS`** +- Occurrence lines under that section use **activity text only** (the tool drops the leading `MS`/`HS` and the repeated event name so lines read like the PDF, e.g. `On-Site Preliminary Exam …` not `MS Cybersecurity On-Site …`). +- Only rows whose cell text has a **leading** `MS `, `HS `, `MS/`, or `HS/` prefix (after normalization) are grouped; the remainder is fuzzy-matched to an event (see `OccurrenceEventMatcher`). +- **`MS/HS …`** combined rows → **General Schedule** (no single section). +- Unmatched lines (opening session, meet-the-candidates, help desk, etc.) stay under a final **`General Schedule`** block. + +Use **`--no-group-by-event`** to force the old “one General Schedule per sheet” layout. + +## Site-wide rows (e.g. CURFEW) + +If the same label appears across **every location column** for the same time (typical for **CURFEW**), the tool emits **one line per date and time** with **no location**. Built-in: `CURFEW` (case-insensitive). Optional mapping field **`siteWideEventNames`** adds more titles (e.g. `["Fire drill"]`). + +## Run the tool + +From the repository root: + +```powershell +$env:GOOGLE_SHEETS_API_KEY = "" + +dotnet run --project tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj -- ` + --sheet-url "https://docs.google.com/spreadsheets/d//edit" ` + --mapping path/to/mapping.json ` + --output path/to/event-times.txt +``` + +Optional flags: + +| Flag | Purpose | +|------|--------| +| `--year 2026` | Year for date validation (also in mapping file). | +| `--tabs "Day 1,Day 2"` | Only these tab titles (exact match). | +| `--events-csv path\to\Event Definitions.csv` | Load event names for stricter parser validation (`Event` column). | +| `--strict` | Exit code `1` if the built-in parser reports errors or parses zero occurrences. | + +The tool always runs a **parser round-trip** on the generated text and prints errors/issues to the console. + +## Import into the app + +1. Open **Import Event Occurrences** in the web app. +2. Paste the contents of the generated `.txt` file. +3. **Parse**, review results, then **Save to Database** as usual. + +## Troubleshooting + +- **403 / access denied:** Confirm the sheet is visible to the API key and Sheets API is enabled. +- **Wrong durations:** Ensure time labels in column A are consistent; the tool infers slot length from the most common delta between consecutive rows. +- **Parser issues on import:** Use `--events-csv` pointing at your chapter’s event definitions export; fix `sectionHeader` in mapping if items should sit under a specific `Event Name - MS/HS` section. diff --git a/docs/notes/event-times-2026.txt b/docs/notes/event-times-2026.txt new file mode 100644 index 0000000..2e0cca0 --- /dev/null +++ b/docs/notes/event-times-2026.txt @@ -0,0 +1,163 @@ +# THURSDAY 4/9 +General Schedule +Registration April 9 3 p.m. - 7 p.m. CCC Lobby (outside of Banquet Rooms) +FCCLA/TSA Store April 9 3 p.m. - 7 p.m. Meeting Room 1 +MS/HS Prompt Releases (Virtual via App) April 9 6 p.m. - 9 p.m. Banquet Room E +MS/HS Event Turn-In April 9 6 p.m. - 9 p.m. Banquet Room G +HS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room H +MS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room I +MEMCO Meeting April 9 6 p.m. - 7 p.m. Banquet Room J +MS/HS Time Sign-Ups (Virtual via App) April 9 6:30 p.m. - 7:30 p.m. Banquet Room F +Coordinators Meeting April 9 7 p.m. - 8 p.m. Banquet Room J +SOT Candidates Meeting April 9 8 p.m. - 9:30 p.m. Banquet Room J +CURFEW April 9 11 p.m. - 12:30 a.m. + +# FRIDAY 4/10 +CAD foundations - MS +HS 2D CAD Architecture/ HS 3D CAD Engineering On-Site Challenge April 10 10:30 a.m. - 4:30 p.m. Banquet Room G +Career Prep - MS +Semifinals Interviews April 10 1 p.m. - 3 p.m. Meeting Room 10 +Challenging Technology Issues - HS +Debating Technological Issues Prelims Pre-Debate Meeting April 10 10 a.m. - 10:30 a.m. Meeting Room 6 +Debating Technological Issues Prelims Presentation Room (Heat 1) April 10 1 p.m. - 5 p.m. Meeting Room 4 +Debating Technological Issues Prelims Presentation Room (Heat 2) April 10 1 p.m. - 5 p.m. Meeting Room 5 +Challenging Technology Issues - MS +Leadership Strategies Holding Room April 10 10:30 a.m. - 4 p.m. Meeting Room 7 +Prelims Presentation Room April 10 10:30 a.m. - 1 p.m. Meeting Room 8 +Coding - HS +Extemporaneous Speech/Debating Technological Issues Holding Room Room April 10 10 a.m. - 5 p.m. Meeting Room 3 +Data Science & Analytics - HS +"Quarterfinals" Presentation April 10 12:30 p.m. - 5 p.m. Meeting Room 9 +Digital Photography - MS +Semifinals Challenge April 10 10 a.m. - 1 p.m. Meeting Room 19 +Semifinals Interviews April 10 3 p.m. - 4 p.m. Meeting Room 16 +Electrical Applications - MS +Semifinals Challenge April 10 2 p.m. - 3:30 p.m. Meeting Room 19 +Forensic Technology - HS +Future Technology and Engineering Teacher Semifinals Presentations April 10 10 a.m. - 1 p.m. Meeting Room 18 +Leadership Strategies - MS +Prelims Presentation Room April 10 1:30 p.m. - 4 p.m. Meeting Room 8 +Mass Production - HS +Digital Video Production Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 17 +Music Production Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 17 +Prepared Speech - HS +Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4 +Extemporaneous Speech Presentation Room (Heat 2) April 10 10 a.m. - 12:30 p.m. Meeting Room 5 +Prepared Presentation Prelims Presentation April 10 10 a.m. - 3:30 p.m. Meeting Room 21 +Prepared Speech - MS +Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10 +System Control Technology - MS +HS System Control Technology On-Site Challenge April 10 10 a.m. - 2 p.m. Banquet Room F +Tech Bowl - HS +Photographic Tech Semifinals Prompt Release April 10 11 a.m. - 12 p.m. Meeting Room 6 +Video Game Design - MS +Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16 +Website Design - MS +Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 16 +General Schedule +FCCLA/TSA Store April 10 8 a.m. - 4 p.m. Meeting Room 1 +MS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C +HS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C +Opening Session April 10 9 a.m. - 10 a.m. Exhibit Hall A +TECHSPO April 10 10 a.m. - 4 p.m. Main Hallway +Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms) +Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms) +Advisor Meeting April 10 10 a.m. - 11 a.m. Banquet Room E +HS Software Development Semifinals Presentations April 10 10 a.m. - 12 p.m. Meeting Room 9 +MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B +MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B +MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C +MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C +Workshop April 10 11:30 a.m. - 1:30 p.m. Banquet Room E +HS STEM Mass Media Semifinals Press Conference April 10 12:30 p.m. - 3:30 p.m. Meeting Room 6 +TSA Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway +HS Animatronics Presentations/ Interviews April 10 1:30 p.m. - 4:30 p.m. Meeting Room 18 +Workshop April 10 2:30 p.m. - 4:30 p.m. Banquet Room E +HS VR Semifinals Interviews April 10 2:30 p.m. - 4:30 p.m. Banquet Room F +Community Service Video Seminfinal Interviews April 10 3 p.m. - 4 p.m. Meeting Room 17 +TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway +TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway +(no title) April 10 4:30 p.m. - 5:30 p.m. Banquet Room E +Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D +Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D +(no title) April 10 10 p.m. - 11 p.m. Banquet Room E +CURFEW April 10 11 p.m. - 12:30 a.m. + +# SATURDAY 4/11 +Challenging Technology Issues - HS +Debating Technological Issues Semifinals Pre-Debate Meeting April 11 9:30 a.m. - 10 a.m. Meeting Room 7 +Debating Technological Issues Semifinals Presentation Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 8 +Challenging Technology Issues - MS +Semifinals Holding Room April 11 9:30 a.m. - 11 a.m. Meeting Room 9 +Semifinals Presentation Room April 11 9:30 a.m. - 11 a.m. Meeting Room 10 +Chapter Team - HS +Semifinals Presentation April 11 1:30 p.m. - 3:30 p.m. Meeting Room 16 +Chapter Team - MS +Semifinals Presentation April 11 11:30 a.m. - 1 p.m. Meeting Room 16 +Children's Stories - HS +Semifinals Interviews April 11 9:30 a.m. - 12:30 p.m. Meeting Room 18 +Children's Stories - MS +Semifinals Interviews April 11 1 p.m. - 4 p.m. Meeting Room 18 +Coding - HS +Semifinals Challenge April 11 9:30 a.m. - 12 p.m. Meeting Room 19 +Debating Technological Issues Semifinals Holding Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 7 +Extemporaneous Speech Semifinals Holding Room April 11 1 p.m. - 2:30 p.m. Meeting Room 7 +Coding - MS +Semifinals Challenge April 11 12:30 p.m. - 3 p.m. Meeting Room 19 +Cybersecurity - MS +Seminals Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19 +Data Science & Analytics - HS +Forensic Science Written Analysis Room April 11 9:30 a.m. - 2:30 p.m. Meeting Room 5 +Semifinals Challenge April 11 3 p.m. - 5 p.m. Meeting Room 6 +Data Science & Analytics - MS +Data Science and Analytics Presentations April 11 3 p.m. - 4:30 p.m. Meeting Room 4 +Data Science and Analytics Preparation Room April 11 3 p.m. - 4:30 p.m. Meeting Room 5 +Forensic Technology - HS +Photographic Technology Semifinals Interviews April 11 3 p.m. - 5 p.m. Meeting Room 7 +Forensic Technology - MS +Semifinals Presentation Room April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9 +Leadership Strategies - MS +Semifinals Holding Room April 11 11:30 a.m. - 1 p.m. Meeting Room 9 +Semifinals Presentation Room April 11 11:30 a.m. - 1 p.m. Meeting Room 10 +Medical Technology - HS +Fashion Design and Technology Semifinals Presentation/ Interviews April 11 2:30 p.m. - 4:30 p.m. Meeting Room 17 +Prepared Speech - HS +Extemporaneous Speech Semifinals Presentation Room April 11 1 p.m. - 2:30 p.m. Meeting Room 8 +Prepared Presentation Semifinals Presentation Room April 11 3 p.m. - 5 p.m. Meeting Room 8 +Prepared Speech - MS +Semifinals Presentation Room April 11 1:30 p.m. - 3 p.m. Meeting Room 10 +Promotional Marketing - HS +Promotional Design Semifinals Challenge April 11 11:30 a.m. - 2:30 p.m. Meeting Room 6 +Promotional Marketing - MS +Semifinals Challenge April 11 9:30 a.m. - 11 a.m. Meeting Room 6 +STEM Animation - MS +Semifinals Interviews April 11 9:30 a.m. - 11 a.m. Meeting Room 16 +Tech Bowl - MS +HS Technology Bowl Semifinals Bracket Play April 11 9 a.m. - 5 p.m. Banquet Rooms G +HS Technology Bowl Semifinals Holding Room April 11 9 a.m. - 5 p.m. Banquet Rooms H +Video Game Design - HS +Semifinals Interviews April 11 12 p.m. - 2 p.m. Meeting Room 17 +Website Design - HS +Webmaster Semifinals Interviews April 11 9:30 a.m. - 11:30 a.m. Meeting Room 17 +General Schedule +Voting Delegate Meeting April 11 8 a.m. - 9 a.m. Banquet Room F +Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms) +Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms) +Tennessee TSA Store April 11 9 a.m. - 4 p.m. Meeting Room 1 +HS Forensic Science CSI April 11 9:30 a.m. - 2:30 p.m. Meeting Room 4 +MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B +MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B +MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C +MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C +TECHSPO April 11 10 a.m. - 4 p.m. Main Hallway +MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C +MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C +General Session 2: Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A +Senior Social April 11 8:30 p.m. - 9:30 p.m. Banquet Room F +Chapter Officer Meeting April 11 8:30 p.m. - 9 p.m. Banquet Rooms G +CURFEW April 11 11 p.m. - 12:30 a.m. + +# SUNDAY 4/12 +General Schedule +General Session 3: Awards Ceremony April 12 8:30 a.m. - 12 p.m. Exhibit Hall A +New SOT Pictures April 12 12 p.m. - 12:30 p.m. Exhibit Hall A \ No newline at end of file diff --git a/docs/notes/google-sheets-import-sample-mapping.json b/docs/notes/google-sheets-import-sample-mapping.json new file mode 100644 index 0000000..56b4d6b --- /dev/null +++ b/docs/notes/google-sheets-import-sample-mapping.json @@ -0,0 +1,23 @@ +{ + "year": 2026, + "defaultSectionHeader": "General Schedule", + "sheets": [ + { + "title": "Wednesday", + "month": "April", + "day": 2, + "sectionHeader": null + }, + { + "title": "Thursday", + "month": "April", + "day": 3 + }, + { + "title": "Friday", + "month": "April", + "day": 4, + "sectionHeader": "General Schedule" + } + ] +} diff --git a/docs/notes/tnslc-2026-mapping.json b/docs/notes/tnslc-2026-mapping.json new file mode 100644 index 0000000..04c0e7b --- /dev/null +++ b/docs/notes/tnslc-2026-mapping.json @@ -0,0 +1,27 @@ +{ + "year": 2026, + "eventDefinitionsCsv": "../../Tests/Parsers/TestInput/2024 Event Definitions.csv", + "defaultSectionHeader": "General Schedule", + "sheets": [ + { + "title": "THURSDAY 4/9", + "month": "April", + "day": 9 + }, + { + "title": "FRIDAY 4/10", + "month": "April", + "day": 10 + }, + { + "title": "SATURDAY 4/11", + "month": "April", + "day": 11 + }, + { + "title": "SUNDAY 4/12", + "month": "April", + "day": 12 + } + ] +} diff --git a/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs b/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs new file mode 100644 index 0000000..0df18bf --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs @@ -0,0 +1,82 @@ +using System.Text; +using Core.Entities; + +namespace GoogleSheetsScheduleImport; + +/// +/// Loads minimal rows from Tests-style CSV (column "Event"). +/// +public static class EventDefinitionsCsvLoader +{ + public static List Load(string path) + { + var list = new List(); + 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 ParseCsvLine(string line) + { + var result = new List(); + 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; + } +} diff --git a/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs b/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs new file mode 100644 index 0000000..37a1841 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs @@ -0,0 +1,54 @@ +namespace GoogleSheetsScheduleImport; + +/// +/// 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 +/// General Schedule, which the parser maps to . +/// +public static class GlobalEventDeduplicator +{ + private static readonly HashSet BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase) + { + "CURFEW" + }; + + /// + /// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific. + /// + public static List Deduplicate( + IReadOnlyList lines, + IReadOnlyCollection? extraSiteWideNames = null) + { + var siteWide = new HashSet(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(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; + } +} diff --git a/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs b/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs new file mode 100644 index 0000000..bd642a8 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs @@ -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; + +/// +/// Fetches raw grid data via Sheets API (public API key). +/// +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(), + BackgroundKeys = Array.Empty() + }; + } + + 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? 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; + } + } + } + } + + /// Re-run after merges so copied anchor text is also single-line. + 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}"; + } +} diff --git a/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj b/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj new file mode 100644 index 0000000..a191d46 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj @@ -0,0 +1,17 @@ + + + Exe + net9.0 + enable + enable + GoogleSheetsScheduleImport + GoogleSheetsScheduleImport + + + + + + + + + diff --git a/tools/GoogleSheetsScheduleImport/GridSheetModel.cs b/tools/GoogleSheetsScheduleImport/GridSheetModel.cs new file mode 100644 index 0000000..476800a --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/GridSheetModel.cs @@ -0,0 +1,28 @@ +namespace GoogleSheetsScheduleImport; + +/// +/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored). +/// +public sealed class GridSheetModel +{ + public required string SheetTitle { get; init; } + + /// display values, sanitized hyphens; [row][col] + public required string?[][] Values { get; init; } + + /// Optional RGB hex backgrounds for block grouping (#RRGGBB or null) + 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); diff --git a/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs b/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs new file mode 100644 index 0000000..2b8306d --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs @@ -0,0 +1,91 @@ +using System.Text; +using Core.Entities; +using Core.Models; + +namespace GoogleSheetsScheduleImport; + +/// +/// Emits PDF-style section headers Event Name - MS / Event Name - HS before occurrence lines, +/// matching competition schedule imports. +/// +public static class GroupedImportTextEmitter +{ + private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable + { + 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 Lines)> sheets, + IReadOnlyList 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(); + var bySection = new Dictionary>(); + + 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 idToDef, SectionKey key) + { + if (!idToDef.TryGetValue(key.EventDefinitionId, out var def)) + return $"{key.EventDefinitionId} - {key.Level}"; + return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}"; + } +} diff --git a/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs b/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs new file mode 100644 index 0000000..cd4481e --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs @@ -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}"; + } +} diff --git a/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs b/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs new file mode 100644 index 0000000..4379487 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs @@ -0,0 +1,26 @@ +using System.Text; + +namespace GoogleSheetsScheduleImport; + +public static class ImportTextEmitter +{ + /// + /// Builds text compatible with / Import.razor paste target. + /// + public static string Build( + IReadOnlyList<(string SheetTitle, string SectionHeader, List 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(); + } +} diff --git a/tools/GoogleSheetsScheduleImport/MappingConfig.cs b/tools/GoogleSheetsScheduleImport/MappingConfig.cs new file mode 100644 index 0000000..943ee80 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/MappingConfig.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace GoogleSheetsScheduleImport; + +/// +/// JSON config: sheet tab titles mapped to calendar days plus optional defaults. +/// +public sealed class MappingConfig +{ + /// Calendar year for occurrence dates (overridden by CLI --year). + public int? Year { get; set; } + + /// + /// Emitted before occurrence lines for each sheet (unless overridden per sheet). + /// Use "General Schedule" when grid cells are generic schedule items. + /// + public string? DefaultSectionHeader { get; set; } + + /// Per-sheet overrides and day mapping. + public List? Sheets { get; set; } + + /// + /// 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. + /// + public List? SiteWideEventNames { get; set; } + + /// + /// Path to event definitions CSV (column Event), relative to this mapping file or absolute. + /// When set (or when --events-csv is passed), output is grouped into Event - MS/HS sections when possible. + /// + public string? EventDefinitionsCsv { get; set; } +} + +public sealed class SheetDayMapping +{ + /// Exact tab title as it appears in Google Sheets. + public string Title { get; set; } = ""; + + /// Month name matching EventOccurrenceGrammar (e.g. "April"). + public string Month { get; set; } = ""; + + /// Day of month (1-31). + public int Day { get; set; } + + /// Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS"). + 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."); + } +} diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs b/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs new file mode 100644 index 0000000..ea79c57 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs @@ -0,0 +1,31 @@ +using Core.Parsers.EventOccurrence; +using Core.Utility; + +namespace GoogleSheetsScheduleImport; + +public static class OccurrenceChronologicalSort +{ + public static List Sort(IEnumerable 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; + } + } +} diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs b/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs new file mode 100644 index 0000000..9187051 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs @@ -0,0 +1,50 @@ +using Core.Entities; +using Core.Models; + +namespace GoogleSheetsScheduleImport; + +/// +/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not +/// MS EventName Activity when the section is already EventName - MS. +/// +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; + } +} diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs b/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs new file mode 100644 index 0000000..80720b4 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs @@ -0,0 +1,71 @@ +using Core.Entities; +using Core.Models; +using FuzzySharp; + +namespace GoogleSheetsScheduleImport; + +/// +/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers. +/// +public static class OccurrenceEventMatcher +{ + private const int MinTokenScore = 62; + + public static bool TryMatch( + string occurrenceName, + IReadOnlyList 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 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; + } +} diff --git a/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs b/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs new file mode 100644 index 0000000..dfa281c --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs @@ -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 events) + { + var parser = new EventOccurrenceParserService(null); + return parser.ParseFromText(text, events); + } +} diff --git a/tools/GoogleSheetsScheduleImport/Program.cs b/tools/GoogleSheetsScheduleImport/Program.cs new file mode 100644 index 0000000..5851a3d --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/Program.cs @@ -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 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; +} diff --git a/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs b/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs new file mode 100644 index 0000000..8938e6e --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs @@ -0,0 +1,149 @@ +namespace GoogleSheetsScheduleImport; + +/// +/// Interprets location headers + time rows + colored/value blocks as occurrence lines. +/// +public static class ScheduleGridExtractor +{ + public static List Extract( + GridSheetModel grid, + string month, + int day, + List warnings) + { + var result = new List(); + 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 warnings, string sheetTitle) + { + var deltas = new List(); + 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); + } +} diff --git a/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs b/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs new file mode 100644 index 0000000..c96c4ec --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs @@ -0,0 +1,40 @@ +using Core.Models; + +namespace GoogleSheetsScheduleImport; + +/// +/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to . +/// +public static class SchoolLevelPrefixParser +{ + /// Remainder text and school level when unambiguous; null level for MS/HS combined or unknown. + 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"; +} diff --git a/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs b/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs new file mode 100644 index 0000000..59d67f6 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs @@ -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); + + /// + /// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id. + /// + 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)); + } +} diff --git a/tools/GoogleSheetsScheduleImport/TextNormalization.cs b/tools/GoogleSheetsScheduleImport/TextNormalization.cs new file mode 100644 index 0000000..22bfd86 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/TextNormalization.cs @@ -0,0 +1,44 @@ +using System.Text; + +namespace GoogleSheetsScheduleImport; + +/// +/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators. +/// Import text must be one logical line per occurrence. +/// +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(); + } +} diff --git a/tools/GoogleSheetsScheduleImport/TimeCellParser.cs b/tools/GoogleSheetsScheduleImport/TimeCellParser.cs new file mode 100644 index 0000000..e86402d --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/TimeCellParser.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Core.Parsers.EventOccurrence; + +namespace GoogleSheetsScheduleImport; + +/// +/// Parses time labels from the first column of schedule grids. +/// +public static class TimeCellParser +{ + private static readonly Regex ClockRegex = new( + @"^(?\d{1,2})(?::(?\d{2}))?\s*(?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; + } + } +} diff --git a/tools/GoogleSheetsScheduleImport/TimeFormatter.cs b/tools/GoogleSheetsScheduleImport/TimeFormatter.cs new file mode 100644 index 0000000..c627228 --- /dev/null +++ b/tools/GoogleSheetsScheduleImport/TimeFormatter.cs @@ -0,0 +1,26 @@ +namespace GoogleSheetsScheduleImport; + +/// +/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns). +/// +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)}"; + } +}