using Core.Entities; using Core.Models; using Core.Notes; namespace Core.Parsers; /// /// Parses leftover CSV columns (not roster or ranking) into student note field merges. /// public class StudentNotesFieldParser : CsvParserBase { private static readonly HashSet ReservedHeaders = new(StringComparer.OrdinalIgnoreCase) { "Student Name", "Grade", "TSA year", "State ID", "Regional ID", "National ID", "Officer", "TOTAL # OF EVENTS" }; public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) { } public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) { } /// /// Roster and ranking columns that must not become imported note fields. /// public static bool IsReservedHeader(string? header) { if (string.IsNullOrWhiteSpace(header)) return true; var trimmed = header.Trim(); if (ReservedHeaders.Contains(trimmed)) return true; return int.TryParse(trimmed, out var rank) && rank >= 1 && rank <= StudentEventRanking.MaxRank; } /// /// Leftover field names from a header row after reserved columns are removed. /// public static List GetLeftoverFieldNames(IEnumerable headers) => headers .Where(h => !IsReservedHeader(h)) .Select(h => h!.Trim()) .ToList(); /// /// Reads the header row and returns leftover field names without processing data rows. /// public List PeekLeftoverFieldNames() { CsvReader.Read(); CsvReader.ReadHeader(); return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []); } public StudentNotesImportResult Parse( ICollection students, IReadOnlyDictionary existingNotesByStudentId) { var result = new StudentNotesImportResult(); CsvReader.Read(); CsvReader.ReadHeader(); if (CsvReader.HeaderRecord is null || !CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase)) { result.Errors.Add("CSV must include a 'Student Name' column."); return result; } var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord); result.FieldNames = fieldNames; if (fieldNames.Count == 0) result.Warnings.Add("No leftover field columns were found besides roster and ranking columns."); Dictionary pendingByStudentId = []; while (CsvReader.Read()) { var rowNumber = CsvReader.Context.Parser?.Row ?? 0; var name = CsvReader.GetField("Student Name")?.Trim(); if (string.IsNullOrEmpty(name)) continue; var studentMatch = FuzzyStudentMatcher.Find(students, name); if (studentMatch is null) { result.Issues.Add(new StudentNotesImportIssue { RowNumber = rowNumber, RawStudentName = name, Message = $"No student matched '{name}'." }); continue; } var (student, score) = studentMatch.Value; if (!pendingByStudentId.TryGetValue(student.Id, out var pending)) { pending = new PendingStudentFields(student); pendingByStudentId[student.Id] = pending; } pending.RawStudentName = name; pending.RowNumber = rowNumber; pending.StudentScore = score; foreach (var fieldName in fieldNames) { var raw = CsvReader.GetField(fieldName); pending.Fields[fieldName.Trim()] = raw ?? string.Empty; } } foreach (var pending in pendingByStudentId.Values) { List incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))]; existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown); var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming); result.Matches.Add(new StudentNotesImportMatch { Student = pending.Student, RawStudentName = pending.RawStudentName, RowNumber = pending.RowNumber, StudentScore = pending.StudentScore, IncomingFields = incoming, Merge = merge }); } if (result.Matches.Count == 0 && result.Errors.Count == 0) result.Warnings.Add("No students were matched from the CSV."); return result; } private sealed class PendingStudentFields(Student student) { public Student Student { get; } = student; public string RawStudentName { get; set; } = string.Empty; public int RowNumber { get; set; } public int StudentScore { get; set; } public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase); } }