Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f400813667 |
@@ -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);
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user