Author SHA1 Message Date
poprhythm f400813667 Update EventOccurrence parsing to use EventOccurrenceParseGroup for improved data structure
This commit refactors the EventOccurrence parsing logic to utilize the EventOccurrenceParseGroup class, enhancing the organization of parsed occurrences by grouping them based on event definitions and optional section levels. The changes include updates to the EventOccurrenceParseResult, EventOccurrenceParser, and EventOccurrenceParserService to accommodate the new grouping structure. Additionally, tests are modified to reflect these changes, ensuring that the parsing functionality remains intact and accurate. This refactor improves data handling and aligns with the overall architecture of the application.
2026-04-04 21:55:44 -04:00
39 changed files with 1896 additions and 93 deletions
+10
View File
@@ -0,0 +1,10 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Groups parsed occurrences by event definition and optional section school level from headers
/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same <see cref="EventDefinition"/>
/// can appear in multiple groups.
/// </summary>
public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel);
+4 -4
View File
@@ -9,11 +9,11 @@ namespace Core.Models;
public class EventOccurrenceParseResult public class EventOccurrenceParseResult
{ {
/// <summary> /// <summary>
/// Dictionary of parsed event occurrences, keyed by EventDefinition. /// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering), /// Special events use <see cref="EventOccurrenceParseGroup.EventDefinition"/> static instances with
/// the EventDefinition key will be the static instance. /// <see cref="EventOccurrenceParseGroup.SectionSchoolLevel"/> typically null.
/// </summary> /// </summary>
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>(); public IDictionary<EventOccurrenceParseGroup, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<EventOccurrence>>();
/// <summary> /// <summary>
/// List of parsing errors (critical issues that prevented parsing). /// List of parsing errors (critical issues that prevented parsing).
+9 -7
View File
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Core.Entities; using Core.Entities;
using Core.Models; using Core.Models;
using EventOccurrenceParsers = Core.Parsers.EventOccurrence; using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
@@ -12,7 +12,7 @@ namespace Core.Parsers;
/// </summary> /// </summary>
public class EventOccurrenceParserResult public class EventOccurrenceParserResult
{ {
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>(); public IDictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>>();
public List<ParsingIssue> Issues { get; set; } = new(); public List<ParsingIssue> Issues { get; set; } = new();
public List<string> SkippedSectionHeaders { get; set; } = new(); public List<string> SkippedSectionHeaders { get; set; } = new();
public int SkippedEventCount { get; set; } public int SkippedEventCount { get; set; }
@@ -296,12 +296,14 @@ public class EventOccurrenceParser
Location = location Location = location
}; };
if (!occurrences.ContainsKey(eventDefinition)) var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
occurrences.Add(eventDefinition, []); if (!occurrences.TryGetValue(groupKey, out var groupList))
occurrences[eventDefinition].Add(eventOccurrence); {
groupList = [];
occurrences[groupKey] = groupList;
}
// Reset section level when we successfully parse an occurrence (means we're in a valid section) groupList.Add(eventOccurrence);
currentSectionLevel = null;
} }
return result; return result;
@@ -65,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
// Convert parsed occurrences to result format, handling special event types // Convert parsed occurrences to result format, handling special event types
foreach (var kvp in parsedOccurrences) foreach (var kvp in parsedOccurrences)
{ {
var eventDefinition = kvp.Key; var group = kvp.Key;
var eventDefinition = group.EventDefinition;
var occurrences = kvp.Value; var occurrences = kvp.Value;
// Check if this is a special event type (not stored in database) // 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[group] = occurrences;
result.Occurrences[eventDefinition] = occurrences;
} }
else else
{ {
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
occurrence.SpecialEventType = null; occurrence.SpecialEventType = null;
} }
result.Occurrences[eventDefinition] = occurrences; result.Occurrences[group] = occurrences;
} }
} }
+6
View File
@@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
ProjectSection(ProjectDependencies) = postProject ProjectSection(ProjectDependencies) = postProject
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF} {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}.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.ActiveCfg = Release|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -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<ParsedOccurrenceLine>
{
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);
});
}
}
@@ -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<ParsedOccurrenceLine> Lines)>
{
("Thursday", "General Schedule", new List<ParsedOccurrenceLine>
{
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>
{
EventDefinition.GeneralSchedule
});
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
Assert.That(result.TotalParsed, Is.EqualTo(1));
});
}
}
@@ -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"));
}
}
@@ -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<EventDefinition> { 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<EventDefinition> { 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);
}
}
@@ -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<string>();
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."));
});
}
}
@@ -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"));
}
}
@@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
// Verify successful occurrence is still parsed (if any valid lines exist) // Verify successful occurrence is still parsed (if any valid lines exist)
// The "Valid Event" line should parse successfully despite other issues // The "Valid Event" line should parse successfully despite other issues
var validEvent = events.First(e => e.Name == "Valid Event"); 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, // 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 // 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) // Verify occurrences were parsed correctly (if they were parsed)
var testEvent = events.First(e => e.Name == "Test Event"); 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.Name, Is.EqualTo("Test Event"));
Assert.That(occurrence.Location, Is.EqualTo("Room 101")); 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 // 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) // Verify locations are extracted correctly (pattern matching is no longer used)
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent) var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
? result.Occurrences[testEvent].FirstOrDefault() ? result.Occurrences[testGroup].FirstOrDefault()
: null; : null;
if (testEventOccurrence != 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") // Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
// General Schedule section uses EventDefinition.GeneralSchedule // General Schedule section uses EventDefinition.GeneralSchedule
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule), var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}"); 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), Assert.That(occurrences, Has.Count.GreaterThan(0),
"Should have at least one occurrence in General Schedule"); "Should have at least one occurrence in General Schedule");
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); 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 finally
{ {
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); 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 finally
{ {
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); 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 finally
{ {
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert // Assert
Assert.That(result.Issues, Has.Count.EqualTo(0)); Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1)); 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 finally
{ {
+79 -54
View File
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
/// <summary> /// <summary>
/// Writes special events summary to console. /// Writes special events summary to console.
/// </summary> /// </summary>
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences) private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{ {
Console.WriteLine($"\n--- Special Events Found ---"); Console.WriteLine($"\n--- Special Events Found ---");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs)) static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences"); occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences"); var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com)) if (gs > 0)
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences"); Console.WriteLine($" GeneralSchedule: {gs} occurrences");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm)) var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences"); 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");
} }
/// <summary> /// <summary>
@@ -237,44 +244,27 @@ public class EventOccurrenceParser_Tests
/// <summary> /// <summary>
/// Writes special events to console output. /// Writes special events to console output.
/// </summary> /// </summary>
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences) private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
{ {
static List<Core.Entities.EventOccurrence> ListFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
Console.WriteLine("General Schedule"); Console.WriteLine("General Schedule");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule)) foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
{
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Meet the Candidates"); Console.WriteLine("Meet the Candidates");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates)) foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
{
foreach (var eo in meetTheCandidates)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Chapter Officer Meeting"); Console.WriteLine("Chapter Officer Meeting");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting)) foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
{
foreach (var eo in chapterOfficerMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Voting Delegate Meeting"); Console.WriteLine("Voting Delegate Meeting");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting)) foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
{
foreach (var eo in votingDelegateMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}"); Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
} }
}
}
#endregion #endregion
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
{ {
Console.WriteLine($"{@event.Name}"); 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}"); Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue; continue;
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
{ {
Console.WriteLine($"{@event.Name}"); 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}"); Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue; continue;
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
// Total expected MS occurrences: 16 // Total expected MS occurrences: 16
var msEventCount = 0; 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; 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; 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; 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; msEventCount += ccOccurrences.Count;
// When no school level is set, HS events should be processed (not skipped) // 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"); Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
// Verify specific locations are parsed // 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 var locations = childrensStoriesOccurrences
.Select(eo => eo.Location) .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"); "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 // With no school level filtering, both MS and HS events are processed
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences)) result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
{ result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
// With no school level set, we process all occurrences (both MS and HS) msOccurrences ??= [];
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total hsOccurrences ??= [];
Assert.That(allOccurrences, Has.Count.EqualTo(5), Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " + Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
$"Found {allOccurrences.Count} occurrences total.");
// Verify all expected occurrence names are present var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList(); Assert.That(allNames, Does.Contain("Submit Entry"));
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences"); Assert.That(allNames, Does.Contain("Judging"));
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences"); Assert.That(allNames, Does.Contain("Pick-up"));
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
}
Assert.Pass("All events processed when no school level is set"); Assert.Pass("All events processed when no school level is set");
} }
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile); 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);
}
}
} }
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -18,6 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Parsers\TestInput\2025 Assumptions.csv"> <Content Include="Parsers\TestInput\2025 Assumptions.csv">
@@ -121,7 +121,7 @@
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0) @if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{ {
<MudAlert Severity="Severity.Success" Dense="true"> <MudAlert Severity="Severity.Success" Dense="true">
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) @if (_parseResult.SkippedEventCount > 0)
{ {
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text> <text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
@@ -201,9 +201,11 @@
{ {
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText> <MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
<MudExpansionPanels Elevation="0"> <MudExpansionPanels Elevation="0">
@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 }))
{ {
<MudExpansionPanel Text="@GetEventName(kvp.Key)"> <MudExpansionPanel Text="@GetOccurrenceGroupTitle(kvp.Key)">
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true"> <MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Name</MudTh>
@@ -395,6 +397,17 @@
return eventDefinition.Name; 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) private Color GetIssueTypeColor(ParsingIssueType issueType)
{ {
return issueType switch return issueType switch
@@ -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 = "<your-api-key>"
dotnet run --project tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj -- `
--sheet-url "https://docs.google.com/spreadsheets/d/<spreadsheetId>/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.
+163
View File
@@ -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
@@ -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"
}
]
}
+27
View File
@@ -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
}
]
}
@@ -0,0 +1,82 @@
using System.Text;
using Core.Entities;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Loads minimal <see cref="EventDefinition"/> rows from Tests-style CSV (column "Event").
/// </summary>
public static class EventDefinitionsCsvLoader
{
public static List<EventDefinition> Load(string path)
{
var list = new List<EventDefinition>();
var lines = File.ReadAllLines(path);
if (lines.Length < 2)
return list;
var header = ParseCsvLine(lines[0]);
var eventIdx = header.FindIndex(h => h.Equals("Event", StringComparison.OrdinalIgnoreCase));
if (eventIdx < 0)
throw new InvalidOperationException($"CSV '{path}' must contain an 'Event' column header.");
for (var i = 1; i < lines.Length; i++)
{
if (string.IsNullOrWhiteSpace(lines[i]))
continue;
var cols = ParseCsvLine(lines[i]);
if (eventIdx >= cols.Count)
continue;
var name = cols[eventIdx].Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
list.Add(new EventDefinition
{
Id = i,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
});
}
return list;
}
private static List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var cur = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
cur.Append('"');
i++;
}
else inQuotes = false;
}
else cur.Append(ch);
}
else
{
if (ch == '"') inQuotes = true;
else if (ch == ',')
{
result.Add(cur.ToString());
cur.Clear();
}
else cur.Append(ch);
}
}
result.Add(cur.ToString());
return result;
}
}
@@ -0,0 +1,54 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Collapses duplicate site-wide rows (same event name, date, time) that appear in every location column
/// (e.g. CURFEW shaded across all rooms) into a single line with no location — still under
/// <c>General Schedule</c>, which the parser maps to <see cref="Core.Entities.EventDefinition.GeneralSchedule"/>.
/// </summary>
public static class GlobalEventDeduplicator
{
private static readonly HashSet<string> BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase)
{
"CURFEW"
};
/// <summary>
/// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific.
/// </summary>
public static List<ParsedOccurrenceLine> Deduplicate(
IReadOnlyList<ParsedOccurrenceLine> lines,
IReadOnlyCollection<string>? extraSiteWideNames = null)
{
var siteWide = new HashSet<string>(BuiltinSiteWideNames, StringComparer.OrdinalIgnoreCase);
if (extraSiteWideNames != null)
{
foreach (var n in extraSiteWideNames)
{
if (!string.IsNullOrWhiteSpace(n))
siteWide.Add(n.Trim());
}
}
var seen = new HashSet<(string Name, string Month, int Day, string Time)>();
var ordered = lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol).ToList();
var result = new List<ParsedOccurrenceLine>(ordered.Count);
foreach (var line in ordered)
{
var name = TextNormalization.ForEmitLine(line.Name);
if (string.IsNullOrEmpty(name) || !siteWide.Contains(name))
{
result.Add(line);
continue;
}
var key = (name, line.Month, line.Day, line.TimeRange);
if (!seen.Add(key))
continue;
result.Add(line with { Location = string.Empty, Name = name });
}
return result;
}
}
@@ -0,0 +1,143 @@
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using Color = Google.Apis.Sheets.v4.Data.Color;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Fetches raw grid data via Sheets API (public API key).
/// </summary>
public sealed class GoogleSheetGridReader
{
private readonly SheetsService _service;
public GoogleSheetGridReader(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new ArgumentException("API key is required for Google Sheets access.", nameof(apiKey));
_service = new SheetsService(new Google.Apis.Services.BaseClientService.Initializer
{
ApiKey = apiKey,
ApplicationName = "TSA GoogleSheetsScheduleImport"
});
}
public Spreadsheet FetchSpreadsheet(string spreadsheetId)
{
var req = _service.Spreadsheets.Get(spreadsheetId);
req.IncludeGridData = true;
return req.Execute();
}
public static GridSheetModel BuildModel(Sheet sheet)
{
var title = sheet.Properties?.Title ?? "(untitled)";
var grid = sheet.Data?.FirstOrDefault();
if (grid?.RowData == null || grid.RowData.Count == 0)
{
return new GridSheetModel
{
SheetTitle = title,
Values = Array.Empty<string?[]>(),
BackgroundKeys = Array.Empty<string?[]>()
};
}
var rowCount = grid.RowData.Count;
var colCount = grid.RowData.Max(r => r.Values?.Count ?? 0);
var values = new string?[rowCount][];
var bg = new string?[rowCount][];
for (var r = 0; r < rowCount; r++)
{
values[r] = new string?[colCount];
bg[r] = new string?[colCount];
var row = grid.RowData[r];
for (var c = 0; c < colCount; c++)
{
string? text = null;
string? hex = null;
if (row.Values != null && c < row.Values.Count)
{
var cell = row.Values[c];
text = string.IsNullOrWhiteSpace(cell.FormattedValue)
? cell.EffectiveValue?.StringValue
: cell.FormattedValue;
if (!string.IsNullOrEmpty(text))
text = TextNormalization.ForSheetCell(text);
var color = cell.UserEnteredFormat?.BackgroundColor
?? cell.EffectiveFormat?.BackgroundColor;
hex = ColorToHexKey(color);
}
values[r][c] = string.IsNullOrWhiteSpace(text) ? null : text;
bg[r][c] = hex;
}
}
ApplyMerges(sheet.Merges, values, bg);
NormalizeAllValueCells(values);
return new GridSheetModel
{
SheetTitle = title,
Values = values,
BackgroundKeys = bg
};
}
private static void ApplyMerges(IList<GridRange>? merges, string?[][] values, string?[][] bg)
{
if (merges == null || merges.Count == 0)
return;
foreach (var range in merges)
{
var r0 = range.StartRowIndex ?? 0;
var r1 = range.EndRowIndex ?? r0;
var c0 = range.StartColumnIndex ?? 0;
var c1 = range.EndColumnIndex ?? c0;
if (r1 <= r0 || c1 <= c0)
continue;
var anchorText = values[r0][c0];
var anchorBg = bg[r0][c0];
for (var r = r0; r < r1; r++)
{
for (var c = c0; c < c1; c++)
{
if (values[r][c] == null && anchorText != null)
values[r][c] = anchorText;
if (bg[r][c] == null && anchorBg != null)
bg[r][c] = anchorBg;
}
}
}
}
/// <summary>Re-run after merges so copied anchor text is also single-line.</summary>
private static void NormalizeAllValueCells(string?[][] values)
{
for (var r = 0; r < values.Length; r++)
for (var c = 0; c < values[r].Length; c++)
{
if (values[r][c] is { } v && !string.IsNullOrWhiteSpace(v))
values[r][c] = TextNormalization.ForEmitLine(v);
}
}
private static string? ColorToHexKey(Color? color)
{
if (color == null)
return null;
var r = color.Red ?? 1f;
var g = color.Green ?? 1f;
var b = color.Blue ?? 1f;
// Treat near-white as no color key for grouping
if (r >= 0.99f && g >= 0.99f && b >= 0.99f)
return null;
static byte F(float x) => (byte)(Math.Clamp(x, 0f, 1f) * 255f);
return $"#{F(r):X2}{F(g):X2}{F(b):X2}";
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GoogleSheetsScheduleImport</AssemblyName>
<RootNamespace>GoogleSheetsScheduleImport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.70.0.3806" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored).
/// </summary>
public sealed class GridSheetModel
{
public required string SheetTitle { get; init; }
/// <summary>display values, sanitized hyphens; [row][col]</summary>
public required string?[][] Values { get; init; }
/// <summary>Optional RGB hex backgrounds for block grouping (#RRGGBB or null)</summary>
public required string?[][]? BackgroundKeys { get; init; }
public int RowCount => Values.Length;
public int ColCount => Values.Length == 0 ? 0 : Values[0].Length;
}
public readonly record struct ParsedOccurrenceLine(
string Name,
string Month,
int Day,
string TimeRange,
string Location,
int SourceRowStart,
int SourceRowEnd,
int SourceCol);
@@ -0,0 +1,91 @@
using System.Text;
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Emits PDF-style section headers <c>Event Name - MS</c> / <c>Event Name - HS</c> before occurrence lines,
/// matching competition schedule imports.
/// </summary>
public static class GroupedImportTextEmitter
{
private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable<SectionKey>
{
public int EventDefinitionId { get; } = eventDefinitionId;
public SchoolLevel Level { get; } = level;
public bool Equals(SectionKey other) =>
EventDefinitionId == other.EventDefinitionId && Level == other.Level;
public override bool Equals(object? obj) => obj is SectionKey other && Equals(other);
public override int GetHashCode() => HashCode.Combine(EventDefinitionId, Level);
}
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets,
IReadOnlyList<EventDefinition> matchableEvents,
int year)
{
var idToDef = matchableEvents.Where(e => e.Id != 0).ToDictionary(e => e.Id);
var sb = new StringBuilder();
foreach (var (sheetTitle, _, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
var general = new List<ParsedOccurrenceLine>();
var bySection = new Dictionary<SectionKey, List<ParsedOccurrenceLine>>();
foreach (var line in lines)
{
if (!OccurrenceEventMatcher.TryMatch(line.Name, matchableEvents, out var evt, out var lvl)
|| evt == null
|| !lvl.HasValue)
{
general.Add(line);
continue;
}
var key = new SectionKey(evt.Id, lvl.Value);
if (!bySection.TryGetValue(key, out var list))
{
list = [];
bySection[key] = list;
}
list.Add(line);
}
foreach (var key in bySection.Keys.OrderBy(k => HeaderSortKey(idToDef, k), StringComparer.OrdinalIgnoreCase))
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
continue;
sb.AppendLine($"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}");
foreach (var line in OccurrenceChronologicalSort.Sort(bySection[key], year))
{
var displayName = OccurrenceDisplayNameReducer.ReduceForSection(line.Name, def, key.Level);
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line with { Name = displayName }));
}
}
if (general.Count > 0)
{
sb.AppendLine("General Schedule");
foreach (var line in OccurrenceChronologicalSort.Sort(general, year))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private static string HeaderSortKey(Dictionary<int, EventDefinition> idToDef, SectionKey key)
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
return $"{key.EventDefinitionId} - {key.Level}";
return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}";
}
}
@@ -0,0 +1,13 @@
namespace GoogleSheetsScheduleImport;
public static class ImportLineFormatter
{
public static string FormatOccurrenceLine(ParsedOccurrenceLine line)
{
var name = TextNormalization.ForEmitLine(line.Name);
var time = TextNormalization.ForEmitLine(line.TimeRange);
var loc = TextNormalization.ForEmitLine(line.Location);
var tail = string.IsNullOrEmpty(loc) ? string.Empty : $" {loc}";
return $"{name} {line.Month} {line.Day} {time}{tail}";
}
}
@@ -0,0 +1,26 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
public static class ImportTextEmitter
{
/// <summary>
/// Builds text compatible with <see cref="Core.Parsers.EventOccurrenceParser"/> / Import.razor paste target.
/// </summary>
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets)
{
var sb = new StringBuilder();
foreach (var (sheetTitle, sectionHeader, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
sb.AppendLine(sectionHeader);
foreach (var line in lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,61 @@
using System.Text.Json.Serialization;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// JSON config: sheet tab titles mapped to calendar days plus optional defaults.
/// </summary>
public sealed class MappingConfig
{
/// <summary>Calendar year for occurrence dates (overridden by CLI --year).</summary>
public int? Year { get; set; }
/// <summary>
/// Emitted before occurrence lines for each sheet (unless overridden per sheet).
/// Use "General Schedule" when grid cells are generic schedule items.
/// </summary>
public string? DefaultSectionHeader { get; set; }
/// <summary>Per-sheet overrides and day mapping.</summary>
public List<SheetDayMapping>? Sheets { get; set; }
/// <summary>
/// Event titles (cell text) to treat as site-wide: one output line per date+time, no location.
/// If omitted, built-in rules still include CURFEW.
/// </summary>
public List<string>? SiteWideEventNames { get; set; }
/// <summary>
/// Path to event definitions CSV (column <c>Event</c>), relative to this mapping file or absolute.
/// When set (or when <c>--events-csv</c> is passed), output is grouped into <c>Event - MS/HS</c> sections when possible.
/// </summary>
public string? EventDefinitionsCsv { get; set; }
}
public sealed class SheetDayMapping
{
/// <summary>Exact tab title as it appears in Google Sheets.</summary>
public string Title { get; set; } = "";
/// <summary>Month name matching EventOccurrenceGrammar (e.g. "April").</summary>
public string Month { get; set; } = "";
/// <summary>Day of month (1-31).</summary>
public int Day { get; set; }
/// <summary>Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS").</summary>
public string? SectionHeader { get; set; }
[JsonIgnore]
public string? NormalizedMonth => string.IsNullOrWhiteSpace(Month) ? null : Month.Trim();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Title))
throw new InvalidOperationException("Mapping entry must include a non-empty Title (sheet tab name).");
if (string.IsNullOrWhiteSpace(Month))
throw new InvalidOperationException($"Sheet '{Title}': Month is required.");
if (Day is < 1 or > 31)
throw new InvalidOperationException($"Sheet '{Title}': Day must be between 1 and 31.");
}
}
@@ -0,0 +1,31 @@
using Core.Parsers.EventOccurrence;
using Core.Utility;
namespace GoogleSheetsScheduleImport;
public static class OccurrenceChronologicalSort
{
public static List<ParsedOccurrenceLine> Sort(IEnumerable<ParsedOccurrenceLine> lines, int year)
{
return lines
.OrderBy(l => Key(l, year))
.ThenBy(l => l.SourceRowStart)
.ThenBy(l => l.SourceCol)
.ToList();
}
private static DateTime Key(ParsedOccurrenceLine line, int year)
{
try
{
var d = TextUtil.ParseDate(line.Month, line.Day.ToString(), year);
var timePart = TimeParser.ExtractStartTime(line.TimeRange);
var t = TimeParser.Parse(timePart);
return new DateTime(d, t);
}
catch
{
return DateTime.MaxValue;
}
}
}
@@ -0,0 +1,50 @@
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not
/// <c>MS EventName Activity</c> when the section is already <c>EventName - MS</c>.
/// </summary>
public static class OccurrenceDisplayNameReducer
{
public static string ReduceForSection(string occurrenceName, EventDefinition matched, SchoolLevel level)
{
var (remainder, lvl) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(occurrenceName);
if (!lvl.HasValue || lvl.Value != level)
return TextNormalization.ForEmitLine(occurrenceName);
var r = remainder.Trim();
var eventName = matched.Name.Trim();
if (r.StartsWith(eventName, StringComparison.OrdinalIgnoreCase))
{
r = r[eventName.Length..].TrimStart();
r = TrimLeadingJoiners(r);
}
r = TextNormalization.ForEmitLine(r);
if (string.IsNullOrWhiteSpace(r))
return TextNormalization.ForEmitLine(remainder.Trim());
return r;
}
private static string TrimLeadingJoiners(string s)
{
var r = s;
while (r.Length > 0)
{
var c = r[0];
if (c is '/' or '-' or ':' or '&' or '–' or '—' or ',' or '.')
{
r = r[1..].TrimStart();
continue;
}
break;
}
return r;
}
}
@@ -0,0 +1,71 @@
using Core.Entities;
using Core.Models;
using FuzzySharp;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers.
/// </summary>
public static class OccurrenceEventMatcher
{
private const int MinTokenScore = 62;
public static bool TryMatch(
string occurrenceName,
IReadOnlyList<EventDefinition> events,
out EventDefinition? matched,
out SchoolLevel? level)
{
matched = null;
level = null;
if (string.IsNullOrWhiteSpace(occurrenceName) || events.Count == 0)
return false;
var normalized = TextNormalization.ForEmitLine(occurrenceName);
var (remainder, prefixLevel) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(normalized);
// Section headers require "Event Name - MS|HS". Titles without a clear school prefix stay in General Schedule.
if (!prefixLevel.HasValue)
return false;
var best = FindBest(remainder, events);
if (best == null || string.IsNullOrWhiteSpace(remainder))
return false;
matched = best;
level = prefixLevel;
return true;
}
private static EventDefinition? FindBest(string text, IReadOnlyList<EventDefinition> events)
{
if (string.IsNullOrWhiteSpace(text))
return null;
EventDefinition? best = null;
var bestScore = 0;
foreach (var e in events)
{
if (string.IsNullOrWhiteSpace(e.Name))
continue;
var score = Math.Max(
Fuzz.TokenSetRatio(text, e.Name),
Fuzz.PartialRatio(text, e.Name));
if (score > bestScore)
{
bestScore = score;
best = e;
}
else if (score == bestScore && best != null && e.Name.Length > best.Name.Length)
best = e;
}
if (best == null || bestScore < MinTokenScore)
return null;
return best;
}
}
@@ -0,0 +1,14 @@
using Core.Entities;
using Core.Models;
using Core.Services;
namespace GoogleSheetsScheduleImport;
public static class ParserRoundTripValidator
{
public static EventOccurrenceParseResult Validate(string text, ICollection<EventDefinition> events)
{
var parser = new EventOccurrenceParserService(null);
return parser.ParseFromText(text, events);
}
}
+242
View File
@@ -0,0 +1,242 @@
using System.Linq;
using System.Text.Json;
using Core.Entities;
using Core.Parsers;
using Google.Apis.Sheets.v4.Data;
using GoogleSheetsScheduleImport;
static void PrintUsage()
{
Console.Error.WriteLine("""
Google Sheets -> Event occurrence import text (for /calendar/event-occurrences/import)
Required:
--sheet-url <url-or-id> Google Sheet URL or raw spreadsheet id
--mapping <path.json> Tab titles -> month/day (+ optional section headers)
--output <path.txt> Output file path
Optional:
--year <yyyy> Calendar year (overrides mapping file)
--api-key <key> Google API key (else env GOOGLE_SHEETS_API_KEY)
--tabs <a,b> Only process these tab titles (exact match)
--events-csv <path> Event definitions CSV with 'Event' column (grouping + validation)
--no-group-by-event Keep a single General Schedule block per sheet (no MS/HS sections)
--strict Exit code 1 if parser reports errors or zero occurrences
Environment:
GOOGLE_SHEETS_API_KEY Default API key for Sheets API (spreadsheet must be accessible to the key)
""");
}
try
{
var argsDict = ParseArgs(args);
if (argsDict.ContainsKey("help") || argsDict.ContainsKey("h"))
{
PrintUsage();
return 0;
}
var sheetUrl = GetRequired(argsDict, "sheet-url");
var mappingPath = GetRequired(argsDict, "mapping");
var outputPath = GetRequired(argsDict, "output");
var mappingJson = await File.ReadAllTextAsync(mappingPath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip };
var mapping = JsonSerializer.Deserialize<MappingConfig>(mappingJson, options)
?? throw new InvalidOperationException("Mapping file is empty or invalid JSON.");
var year = int.TryParse(argsDict.GetValueOrDefault("year"), out var y) ? y
: mapping.Year ?? DateTime.Now.Year;
var apiKey = argsDict.GetValueOrDefault("api-key")
?? Environment.GetEnvironmentVariable("GOOGLE_SHEETS_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Missing API key: pass --api-key or set GOOGLE_SHEETS_API_KEY.");
return 2;
}
HashSet<string>? tabFilter = null;
if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg))
{
tabFilter = new HashSet<string>(
tabsArg.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
StringComparer.Ordinal);
}
var spreadsheetId = SpreadsheetId.FromUrlOrId(sheetUrl);
var reader = new GoogleSheetGridReader(apiKey);
var spreadsheet = reader.FetchSpreadsheet(spreadsheetId);
var sheetMappings = mapping.Sheets ?? [];
if (sheetMappings.Count == 0)
throw new InvalidOperationException("Mapping file must include a non-empty \"sheets\" array with tab titles and dates.");
foreach (var sm in sheetMappings)
sm.Validate();
var defaultSection = mapping.DefaultSectionHeader?.Trim();
if (string.IsNullOrEmpty(defaultSection))
defaultSection = "General Schedule";
var mappingDir = Path.GetDirectoryName(Path.GetFullPath(mappingPath)) ?? Directory.GetCurrentDirectory();
var eventsCsvArg = argsDict.GetValueOrDefault("events-csv");
var eventsCsvConfigured = mapping.EventDefinitionsCsv;
var eventsCsvPath = !string.IsNullOrWhiteSpace(eventsCsvArg)
? (Path.IsPathRooted(eventsCsvArg) ? eventsCsvArg : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), eventsCsvArg)))
: (!string.IsNullOrWhiteSpace(eventsCsvConfigured)
? Path.GetFullPath(Path.Combine(mappingDir, eventsCsvConfigured!))
: null);
List<EventDefinition> csvEvents = new();
if (!string.IsNullOrWhiteSpace(eventsCsvPath) && File.Exists(eventsCsvPath))
csvEvents = EventDefinitionsCsvLoader.Load(eventsCsvPath);
else if (!string.IsNullOrWhiteSpace(eventsCsvPath))
throw new InvalidOperationException($"Event definitions CSV not found: {eventsCsvPath}");
var groupByEvent = csvEvents.Count > 0 && !argsDict.ContainsKey("no-group-by-event");
List<EventDefinition> validationEvents = MergeValidationEvents(csvEvents);
var warnings = new List<string>();
var sheetOutputs = new List<(string Title, string SectionHeader, List<ParsedOccurrenceLine> Lines)>();
foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty<Sheet>())
{
var title = sheet.Properties?.Title ?? "";
if (tabFilter != null && !tabFilter.Contains(title))
continue;
var dayMap = sheetMappings.FirstOrDefault(m =>
m.Title.Equals(title, StringComparison.Ordinal));
if (dayMap == null)
{
warnings.Add($"Skipping sheet '{title}': no entry in mapping JSON.");
continue;
}
var month = dayMap.NormalizedMonth!;
if (!EventOccurrenceGrammar.MonthNames.Any(m => m.Equals(month, StringComparison.OrdinalIgnoreCase)))
warnings.Add($"Sheet '{title}': month '{month}' is not a standard grammar month name.");
try
{
Core.Utility.TextUtil.ParseDate(month, dayMap.Day.ToString(), year);
}
catch (Exception ex)
{
warnings.Add($"Sheet '{title}': invalid date {month} {dayMap.Day}, {year}: {ex.Message}");
}
var grid = GoogleSheetGridReader.BuildModel(sheet);
var lines = ScheduleGridExtractor.Extract(grid, month, dayMap.Day, warnings);
lines = GlobalEventDeduplicator.Deduplicate(lines, mapping.SiteWideEventNames);
var section = string.IsNullOrWhiteSpace(dayMap.SectionHeader) ? defaultSection : dayMap.SectionHeader!.Trim();
sheetOutputs.Add((title, section, lines));
}
foreach (var sm in sheetMappings)
{
var exists = spreadsheet.Sheets?.Any(s => string.Equals(s.Properties?.Title, sm.Title, StringComparison.Ordinal)) ?? false;
if (!exists)
warnings.Add($"Mapping references sheet '{sm.Title}' but it was not found in the spreadsheet.");
}
var text = groupByEvent
? GroupedImportTextEmitter.Build(sheetOutputs, csvEvents, year)
: ImportTextEmitter.Build(sheetOutputs);
var outDir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
await File.WriteAllTextAsync(outputPath, text, System.Text.Encoding.UTF8);
Console.WriteLine($"Wrote {outputPath} ({text.Length} characters).");
foreach (var w in warnings)
Console.WriteLine($"WARNING: {w}");
var strict = argsDict.ContainsKey("strict");
var parseResult = ParserRoundTripValidator.Validate(text, validationEvents);
Console.WriteLine($"Parser round-trip: success={parseResult.IsSuccess}, occurrences={parseResult.TotalParsed}, issues={parseResult.Issues.Count}, errors={parseResult.Errors.Count}");
foreach (var err in parseResult.Errors)
Console.WriteLine($" ERROR: {err}");
foreach (var issue in parseResult.Issues.Take(20))
Console.WriteLine($" Issue L{issue.LineNumber}: {issue.Message}");
if (parseResult.Issues.Count > 20)
Console.WriteLine($" ... and {parseResult.Issues.Count - 20} more issues.");
if (strict && (!parseResult.IsSuccess || parseResult.TotalParsed == 0))
{
Console.Error.WriteLine("Strict mode: failing due to parser errors or zero occurrences parsed.");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Console.Error.WriteLine(ex.ToString());
return 1;
}
static Dictionary<string, string> ParseArgs(string[] args)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var a = args[i];
if (!a.StartsWith("--", StringComparison.Ordinal))
continue;
var key = a[2..];
if (string.IsNullOrEmpty(key))
continue;
if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
{
d[key] = args[i + 1];
i++;
}
else
d[key] = "true";
}
return d;
}
static string GetRequired(Dictionary<string, string> d, string key)
{
if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v))
throw new InvalidOperationException($"Missing required --{key}");
return v;
}
static List<EventDefinition> MergeValidationEvents(List<EventDefinition> fromCsv)
{
if (fromCsv.Count == 0)
{
return
[
EventDefinition.GeneralSchedule,
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
];
}
var list = new List<EventDefinition>(fromCsv);
foreach (var extra in new EventDefinition[]
{
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
})
{
if (list.All(e => !string.Equals(e.Name, extra.Name, StringComparison.OrdinalIgnoreCase)))
list.Add(extra);
}
return list;
}
@@ -0,0 +1,149 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Interprets location headers + time rows + colored/value blocks as occurrence lines.
/// </summary>
public static class ScheduleGridExtractor
{
public static List<ParsedOccurrenceLine> Extract(
GridSheetModel grid,
string month,
int day,
List<string> warnings)
{
var result = new List<ParsedOccurrenceLine>();
if (grid.RowCount < 2 || grid.ColCount < 2)
{
warnings.Add($"Sheet '{grid.SheetTitle}': grid too small; need at least a header row and time column.");
return result;
}
var locations = new string[grid.ColCount];
for (var c = 1; c < grid.ColCount; c++)
{
var header = grid.Values[0][c];
locations[c] = string.IsNullOrWhiteSpace(header) ? $"Column {c + 1}" : header.Trim();
}
var rowTimes = new TimeOnly?[grid.RowCount];
rowTimes[0] = null;
for (var r = 1; r < grid.RowCount; r++)
{
var cell = grid.Values[r][0];
if (!TimeCellParser.TryParse(cell, out var t))
{
if (!string.IsNullOrWhiteSpace(cell))
warnings.Add($"Sheet '{grid.SheetTitle}' row {r + 1}: could not parse time label '{cell}'.");
rowTimes[r] = null;
}
else
rowTimes[r] = t;
}
var slot = InferSlotDuration(rowTimes, warnings, grid.SheetTitle);
for (var c = 1; c < grid.ColCount; c++)
{
var location = locations[c];
var r = 1;
while (r < grid.RowCount)
{
if (!IsOccupied(grid, r, c))
{
r++;
continue;
}
var startRow = r;
var name = grid.Values[r][c] ?? "";
var key = BlockKey(grid, r, c);
var endRow = r;
while (endRow + 1 < grid.RowCount &&
IsOccupied(grid, endRow + 1, c) &&
BlockKey(grid, endRow + 1, c) == key)
{
endRow++;
}
var startTime = rowTimes[startRow];
if (startTime == null)
{
warnings.Add(
$"Sheet '{grid.SheetTitle}' ({location}): block at row {startRow + 1} has no parseable start time in column A.");
r = endRow + 1;
continue;
}
var endInstant = ComputeEndTime(rowTimes, endRow, grid.RowCount, slot);
var timeRange = TimeFormatter.ToParserTimeRange(startTime.Value, endInstant);
var title = name.Trim();
if (string.IsNullOrEmpty(title))
title = "(no title)";
result.Add(new ParsedOccurrenceLine(
Name: title,
Month: month,
Day: day,
TimeRange: timeRange,
Location: location,
SourceRowStart: startRow,
SourceRowEnd: endRow,
SourceCol: c));
r = endRow + 1;
}
}
return result;
}
private static bool IsOccupied(GridSheetModel grid, int r, int c)
{
var v = grid.Values[r][c];
var bg = grid.BackgroundKeys?[r][c];
if (!string.IsNullOrWhiteSpace(v))
return true;
return bg != null; // colored empty cell
}
private static (string NameKey, string? Bg) BlockKey(GridSheetModel grid, int r, int c)
{
var raw = grid.Values[r][c] ?? "";
var v = raw.Trim();
var bg = grid.BackgroundKeys?[r][c];
return (v, bg);
}
private static TimeOnly ComputeEndTime(TimeOnly?[] rowTimes, int endRow, int rowCount, TimeSpan slot)
{
if (endRow + 1 < rowCount && rowTimes[endRow + 1] != null)
return rowTimes[endRow + 1]!.Value;
var lastStart = rowTimes[endRow] ?? throw new InvalidOperationException();
return lastStart.Add(slot);
}
private static TimeSpan InferSlotDuration(TimeOnly?[] rowTimes, List<string> warnings, string sheetTitle)
{
var deltas = new List<int>();
for (var i = 1; i < rowTimes.Length - 1; i++)
{
if (rowTimes[i] == null || rowTimes[i + 1] == null)
continue;
var minutes = (int)(rowTimes[i + 1]!.Value - rowTimes[i]!.Value).TotalMinutes;
if (minutes > 0 && minutes <= 24 * 60)
deltas.Add(minutes);
}
if (deltas.Count == 0)
{
warnings.Add($"Sheet '{sheetTitle}': could not infer time-slot length from column A; defaulting to 30 minutes.");
return TimeSpan.FromMinutes(30);
}
var g = deltas.GroupBy(d => d).OrderByDescending(g => g.Count()).First();
return TimeSpan.FromMinutes(g.Key);
}
}
@@ -0,0 +1,40 @@
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to <see cref="Core.Entities.EventDefinition.Name"/>.
/// </summary>
public static class SchoolLevelPrefixParser
{
/// <returns>Remainder text and school level when unambiguous; <c>null</c> level for MS/HS combined or unknown.</returns>
public static (string Remainder, SchoolLevel? Level) StripLeadingSchoolPrefix(string raw)
{
var s = TextNormalization.ForEmitLine(raw);
if (string.IsNullOrEmpty(s))
return (s, null);
if (s.StartsWith("MS/HS", StringComparison.OrdinalIgnoreCase))
{
var rest = s[5..].TrimStart();
if (rest.StartsWith('/'))
rest = rest[1..].TrimStart();
return (rest, null);
}
if (s.Length >= 3 && s.StartsWith("MS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
if (s.Length >= 3 && s.StartsWith("MS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
return (s, null);
}
public static string ToSectionSuffix(SchoolLevel level) =>
level == SchoolLevel.MiddleSchool ? "MS" : "HS";
}
@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace GoogleSheetsScheduleImport;
public static class SpreadsheetId
{
private static readonly Regex IdRegex = new(
@"/spreadsheets/d/([a-zA-Z0-9-_]+)",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id.
/// </summary>
public static string FromUrlOrId(string input)
{
var trimmed = input.Trim();
var m = IdRegex.Match(trimmed);
if (m.Success)
return m.Groups[1].Value;
if (trimmed.Length > 20 && !trimmed.Contains('/') && !trimmed.Contains(':'))
return trimmed;
throw new ArgumentException(
"Expected a Google Sheets URL (…/spreadsheets/d/{id}/…) or a raw spreadsheet id.", nameof(input));
}
}
@@ -0,0 +1,44 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators.
/// Import text must be one logical line per occurrence.
/// </summary>
public static class TextNormalization
{
public static string ForSheetCell(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
public static string ForEmitLine(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
private static string CollapseWhitespace(string s)
{
var sb = new StringBuilder(s.Length);
var pendingSpace = false;
foreach (var ch in s)
{
if (char.IsWhiteSpace(ch))
pendingSpace = true;
else
{
if (pendingSpace && sb.Length > 0)
sb.Append(' ');
pendingSpace = false;
sb.Append(ch);
}
}
return sb.ToString().Trim();
}
}
@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Core.Parsers.EventOccurrence;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Parses time labels from the first column of schedule grids.
/// </summary>
public static class TimeCellParser
{
private static readonly Regex ClockRegex = new(
@"^(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ap>a\.?m\.?|p\.?m\.?|AM|PM|am|pm)\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool TryParse(string? cellText, out TimeOnly time)
{
time = default;
if (string.IsNullOrWhiteSpace(cellText))
return false;
var t = cellText.Trim();
if (t.Equals("NOON", StringComparison.OrdinalIgnoreCase))
{
time = new TimeOnly(12, 0);
return true;
}
var m = ClockRegex.Match(t);
if (m.Success)
{
var h = int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture);
var minute = m.Groups["m"].Success ? int.Parse(m.Groups["m"].Value, CultureInfo.InvariantCulture) : 0;
var apStr = m.Groups["ap"].Value;
var isPm = apStr.Contains('P', StringComparison.OrdinalIgnoreCase);
var isAm = apStr.Contains('A', StringComparison.OrdinalIgnoreCase);
if (isPm && h < 12) h += 12;
if (isAm && h == 12) h = 0;
time = new TimeOnly(h, minute);
return true;
}
// Fallback: use core TimeParser if string already looks like parsed format
try
{
time = TimeParser.Parse(t);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,26 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns).
/// </summary>
public static class TimeFormatter
{
public static string ToParserTimeString(TimeOnly time)
{
var h12 = time.Hour % 12;
if (h12 == 0) h12 = 12;
var minute = time.Minute;
var isPm = time.Hour >= 12;
var ap = isPm ? "p.m." : "a.m.";
if (minute == 0)
return $"{h12} {ap}";
return $"{h12}:{minute:D2} {ap}";
}
public static string ToParserTimeRange(TimeOnly start, TimeOnly end)
{
if (start == end)
return ToParserTimeString(start);
return $"{ToParserTimeString(start)} - {ToParserTimeString(end)}";
}
}