Files
chapter-organizer/Core/Parsers/EventOccurrenceParser.cs
T
poprhythm 2d3b29176f Enhance event occurrence parsing with detailed issue reporting and location configuration
This commit introduces a new structure for handling parsing issues in the EventOccurrenceParser, allowing for detailed reporting of parsing problems such as unmatched lines, missing event definitions, and parsing failures for time, date, and location. A new ParsingIssue class has been added to encapsulate these details. Additionally, a LocationParsingConfiguration class has been implemented to support customizable location patterns, enhancing the flexibility of the parser. The EventOccurrenceParserService has been updated to utilize this configuration, and new tests have been added to ensure robust issue detection and reporting. Furthermore, the UI has been updated to display parsing issues, improving user feedback during the import process.
2026-01-06 23:08:42 -05:00

383 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.RegularExpressions;
using Core.Entities;
using Core.Models;
using FuzzySharp;
namespace Core.Parsers;
/// <summary>
/// Result of parsing event occurrence file, containing both occurrences and parsing issues.
/// </summary>
public class EventOccurrenceParserResult
{
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
public List<ParsingIssue> Issues { get; set; } = new();
}
public class EventOccurrenceParser
{
private FileSystemInfo _txtFile;
private ICollection<EventDefinition> _events;
private LocationParsingConfiguration? _locationConfig;
public EventOccurrenceParser(FileSystemInfo txtFile, ICollection<EventDefinition> events, LocationParsingConfiguration? locationConfig = null)
{
_events = events;
_txtFile = txtFile;
_locationConfig = locationConfig;
}
private Regex _re =
new (
@"" + //
@"(?<Name>^[^#].*)\s" +
@"(?<Month>February|March|April|May|June|July)\s" +
@"(?<DayOfMonth>\d{1,2});?\s" +
@"(?<TimeAndLocation>.*)"
);
private readonly Regex _timeRe = new(@"(?<Hour>\d{1,2}):?(?<Minute>\d{2})?\s?(?<APM>(?:a|p)\.?m\.?)");
// Regex to match time ranges like "10:30 a.m. - 12:00 p.m." or "10:30 a.m. - NOON"
// Matches: time1 (optional dash time2/NOON), then location
// The time group captures the full time range (including " - NOON" if present)
// Pattern breakdown:
// - First time: (?:NOON|\d{1,2}:?\d{0,2}\s?(?:[AaPp]\.?[Mm]\.?)) - matches NOON or time with AM/PM
// - Optional range: (?:\s*[-]\s*(?:NOON|\d{1,2}:?\d{0,2}\s?(?:[AaPp]\.?[Mm]\.?))) - matches dash followed by NOON or time
// - Location: \s+.+ - whitespace followed by rest of string
private readonly Regex _timeLocationRegex = new(@"(?<Time>(?:NOON|\d{1,2}:?\d{0,2}\s?(?:[AaPp]\.?[Mm]\.?))(?:\s*[-]\s*(?:NOON|\d{1,2}:?\d{0,2}\s?(?:[AaPp]\.?[Mm]\.?)))?)(?<Location>\s+.+)?");
public EventOccurrenceParserResult Parse()
{
var result = new EventOccurrenceParserResult();
var occurrences = result.Occurrences;
var issues = result.Issues;
EventDefinition? currentEventDefinition = null;
var lines = File.ReadLines(_txtFile.FullName);
foreach (var (line, index) in lines.Select((line, index) => (line, index + 1)))
{
var trimmedLine = line.Trim();
// Skip empty lines
if (string.IsNullOrWhiteSpace(trimmedLine))
continue;
var match = _re.Match(trimmedLine);
if (!match.Success)
{
if (trimmedLine.Contains("MS"))
{
var evt =
(from e in _events
let rat = Fuzz.Ratio(e.Name, trimmedLine)
where rat > 50
orderby rat descending
select e).FirstOrDefault();
if (evt == null)
{
issues.Add(new ParsingIssue
{
LineNumber = index,
LineContent = trimmedLine,
IssueType = ParsingIssueType.UnmatchedLine,
Message = $"Section header with 'MS' found but no matching event definition (best match ratio: {Fuzz.Ratio(trimmedLine, _events.FirstOrDefault()?.Name ?? "")})"
});
continue;
}
currentEventDefinition = evt;
continue;
}
if (trimmedLine == "General Schedule" || trimmedLine == "General Session")
{
currentEventDefinition = EventDefinition.GeneralSchedule;
continue;
}
// "Voting Delegates" section header is no longer used - occurrences are categorized by name pattern
// Track as unmatched line if it's not empty
if (!string.IsNullOrWhiteSpace(trimmedLine))
{
issues.Add(new ParsingIssue
{
LineNumber = index,
LineContent = trimmedLine,
IssueType = ParsingIssueType.UnmatchedLine,
Message = "Line does not match expected format (Name Month Day Time/Location)"
});
}
continue;
}
var occurrenceName = match.Groups["Name"].Captures[0].Value;
var month = match.Groups["Month"].Captures[0].Value;
var dayOfMonth = match.Groups["DayOfMonth"].Captures[0].Value;
var timeAndLocation = match.Groups["TimeAndLocation"].Captures[0].Value;
occurrenceName = Regex.Replace(occurrenceName,
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
// Determine event definition based on occurrence name pattern or current section
EventDefinition? eventDefinition = DetermineEventDefinition(occurrenceName, currentEventDefinition);
// Track issue if we can't determine the event definition
if (eventDefinition == null)
{
issues.Add(new ParsingIssue
{
LineNumber = index,
LineContent = trimmedLine,
IssueType = ParsingIssueType.MissingEventDefinition,
Message = $"Cannot determine event definition for occurrence: {occurrenceName}"
});
continue;
}
timeAndLocation = SanitizeInput(timeAndLocation);
// Parse time and location using configurable patterns
var (time, location, locationParseSuccess) = ParseTimeAndLocation(timeAndLocation, index, trimmedLine, issues);
// Parse date
DateOnly? startDate = null;
try
{
startDate = ParseDate(month, dayOfMonth, DateTime.Now.Year);
}
catch (Exception ex)
{
issues.Add(new ParsingIssue
{
LineNumber = index,
LineContent = trimmedLine,
IssueType = ParsingIssueType.DateParseFailure,
Message = $"Failed to parse date: {ex.Message}"
});
continue;
}
// Parse time
TimeOnly? startTime = null;
try
{
startTime = ParseStartTime(time);
}
catch (Exception ex)
{
issues.Add(new ParsingIssue
{
LineNumber = index,
LineContent = trimmedLine,
IssueType = ParsingIssueType.TimeParseFailure,
Message = $"Failed to parse time '{time}': {ex.Message}"
});
continue;
}
if (startDate == null || startTime == null)
continue;
var t = new DateTime(startDate.Value, startTime.Value);
var eventOccurrence = new EventOccurrence
{
Name = occurrenceName,
StartTime = t,
Time = $"{time}",
Date = $"{month} {dayOfMonth}",
Location = location
};
if (!occurrences.ContainsKey(eventDefinition))
occurrences.Add(eventDefinition, []);
occurrences[eventDefinition].Add(eventOccurrence);
}
return result;
}
/// <summary>
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
/// </summary>
private EventDefinition? DetermineEventDefinition(string occurrenceName, EventDefinition? currentEventDefinition)
{
// Check for special event name patterns first (regardless of current section)
if (occurrenceName.Contains("Meet the Candidates Session", StringComparison.OrdinalIgnoreCase))
return EventDefinition.MeetTheCandidates;
if (occurrenceName.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase))
return EventDefinition.ChapterOfficerMeeting;
if (occurrenceName.Contains("Voting Delegate Meeting", StringComparison.OrdinalIgnoreCase))
return EventDefinition.VotingDelegateMeeting;
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
if (currentEventDefinition == EventDefinition.GeneralSchedule)
return EventDefinition.GeneralSchedule;
// If we have a current event definition from section header (e.g., regular events), use it
if (currentEventDefinition != null)
return currentEventDefinition;
// Cannot determine event definition
return null;
}
private string SanitizeInput(string input)
{
input = input.Replace("", "-");
input = input.Replace("—", "-");
return input;
}
private DateOnly ParseDate(string month, string dayOfMonth, int year)
{
int monthNum = 1;
switch (month)
{
case "February":
monthNum = 2;
break;
case "March":
monthNum = 3;
break;
case "April":
monthNum = 4;
break;
case "May":
monthNum = 5;
break;
case "June":
monthNum = 6;
break;
case "July":
monthNum = 7;
break;
}
var day = int.Parse(dayOfMonth);
return new DateOnly(year, monthNum, day); ;
}
/// <summary>
/// Parses time and location from the timeAndLocation string using configurable location patterns.
/// </summary>
private (string time, string location, bool locationParseSuccess) ParseTimeAndLocation(
string timeAndLocation,
int lineNumber,
string lineContent,
List<ParsingIssue> issues)
{
var time = timeAndLocation;
var location = string.Empty;
var locationParseSuccess = false;
// First, try to separate time from location using the time regex
var timeLocationMatch = _timeLocationRegex.Match(timeAndLocation);
if (timeLocationMatch.Success)
{
time = timeLocationMatch.Groups["Time"].Captures[0].Value.Trim();
var locationPart = timeLocationMatch.Groups["Location"].Success
? timeLocationMatch.Groups["Location"].Captures[0].Value.Trim()
: string.Empty;
if (!string.IsNullOrWhiteSpace(locationPart))
{
// Try to match location using configurable patterns
if (_locationConfig != null && _locationConfig.LocationPatterns.Any())
{
location = MatchLocationPattern(locationPart, _locationConfig.LocationPatterns);
locationParseSuccess = !string.IsNullOrEmpty(location);
}
// If no pattern matched, fall back to using the location part as-is
if (!locationParseSuccess)
{
location = locationPart;
// Only add issue if we have patterns configured but none matched
if (_locationConfig != null && _locationConfig.LocationPatterns.Any())
{
issues.Add(new ParsingIssue
{
LineNumber = lineNumber,
LineContent = lineContent,
IssueType = ParsingIssueType.LocationParseFailure,
Message = $"Location '{locationPart}' does not match any configured pattern"
});
}
}
}
}
else
{
// If time regex doesn't match, use the whole string as time
time = timeAndLocation.Trim();
}
return (time, location, locationParseSuccess || string.IsNullOrWhiteSpace(location));
}
/// <summary>
/// Matches location text against configured patterns and returns the matched location.
/// </summary>
private string MatchLocationPattern(string locationText, List<string> patterns)
{
foreach (var pattern in patterns)
{
if (!pattern.Contains('*'))
continue;
// Convert pattern to regex: escape special chars, replace * with .*
var escapedPattern = Regex.Escape(pattern);
escapedPattern = escapedPattern.Replace(@"\*", ".*");
var regex = new Regex($"^{escapedPattern}$", RegexOptions.IgnoreCase);
if (regex.IsMatch(locationText))
{
return locationText; // Return the full matched location
}
}
return string.Empty;
}
private TimeOnly ParseStartTime(string time)
{
int hour = 0;
int minute = 0;
// get the part of the time before a timespan
if (time.Contains(" - "))
{
time = time[..time.IndexOf(" - ", StringComparison.Ordinal)];
}
if (time == "NOON")
hour = 12;
else
{
var timeMatch = _timeRe.Match(time.ToLower());
if (timeMatch.Success)
{
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
if (timeMatch.Groups["Minute"].Success)
{
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
}
if (timeMatch.Groups["APM"].Captures[0].Value is "p.m." or "pm" && hour < 12)
hour += 12;
}
else
{
throw new FormatException($"Time format not recognized: {time}");
}
}
return new TimeOnly(hour, minute, 0);
}
}