Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings. Co-authored-by: Cursor <cursoragent@cursor.com>
165 lines
5.3 KiB
C#
165 lines
5.3 KiB
C#
using Core.Entities;
|
|
using Core.Models;
|
|
using Core.Notes;
|
|
|
|
namespace Core.Parsers;
|
|
|
|
/// <summary>
|
|
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
|
|
/// </summary>
|
|
public class StudentNotesFieldParser : CsvParserBase
|
|
{
|
|
private static readonly HashSet<string> 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)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Roster and ranking columns that must not become imported note fields.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Leftover field names from a header row after reserved columns are removed.
|
|
/// </summary>
|
|
public static List<string> GetLeftoverFieldNames(IEnumerable<string?> headers) =>
|
|
headers
|
|
.Where(h => !IsReservedHeader(h))
|
|
.Select(h => h!.Trim())
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Reads the header row and returns leftover field names without processing data rows.
|
|
/// </summary>
|
|
public List<string> PeekLeftoverFieldNames()
|
|
{
|
|
CsvReader.Read();
|
|
CsvReader.ReadHeader();
|
|
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
|
|
}
|
|
|
|
public StudentNotesImportResult Parse(
|
|
ICollection<Student> students,
|
|
IReadOnlyDictionary<int, string?> 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<int, PendingStudentFields> 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<ImportedField> 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<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
}
|