feat: import leftover student fields into notes and show them on the roster

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>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
+48
View File
@@ -0,0 +1,48 @@
using Core.Entities;
using Core.Notes;
namespace Core.Models;
/// <summary>
/// Result of parsing a student notes field CSV.
/// </summary>
public class StudentNotesImportResult
{
public List<StudentNotesImportMatch> Matches { get; set; } = [];
public List<StudentNotesImportIssue> Issues { get; set; } = [];
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public IReadOnlyList<string> FieldNames { get; set; } = [];
public bool IsSuccess => Errors.Count == 0;
public int StudentsWithChanges => Matches.Count(m => m.Merge.Changed);
}
public class StudentNotesImportMatch
{
public required Student Student { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public List<ImportedField> IncomingFields { get; set; } = [];
public required ImportedFieldsMergeResult Merge { get; set; }
}
public class StudentNotesImportIssue
{
public int RowNumber { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
+272
View File
@@ -0,0 +1,272 @@
using System.Text;
namespace Core.Notes;
/// <summary>
/// Parses and upserts a generic Field/Value markdown table under a stable heading.
/// </summary>
public static class ImportedFieldsTable
{
public const string Heading = "## Imported fields";
public static string NormalizeValue(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Equals("x", StringComparison.OrdinalIgnoreCase))
return "Yes";
return value;
}
/// <summary>
/// Reads Field/Value rows from the Imported fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
var section = ExtractSection(markdown);
if (string.IsNullOrWhiteSpace(section))
return [];
List<ImportedField> fields = [];
foreach (var rawLine in section.Split('\n'))
{
var line = rawLine.Trim();
if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal))
continue;
var cells = SplitTableCells(line);
if (cells.Length < 2)
continue;
var field = UnescapeCell(cells[0]);
var value = UnescapeCell(cells[1]);
if (field.Equals("Field", StringComparison.OrdinalIgnoreCase)
&& value.Equals("Value", StringComparison.OrdinalIgnoreCase))
continue;
fields.Add(new ImportedField(field, value));
}
return fields;
}
/// <summary>
/// Upserts incoming fields into the Imported fields section. Incoming values win.
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
/// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
{
var existing = ParseFields(existingMarkdown);
var merged = existing.ToList();
List<ImportedFieldChange> changes = [];
foreach (var incomingField in incoming)
{
var name = incomingField.Name.Trim();
if (string.IsNullOrEmpty(name))
continue;
var value = NormalizeValue(incomingField.Value);
var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
merged.Add(new ImportedField(name, value));
changes.Add(new ImportedFieldChange
{
Field = name,
PreviousValue = null,
NewValue = value,
IsNew = true
});
continue;
}
var current = merged[index];
if (string.Equals(current.Value, value, StringComparison.Ordinal))
continue;
merged[index] = new ImportedField(current.Name, value);
changes.Add(new ImportedFieldChange
{
Field = current.Name,
PreviousValue = current.Value,
NewValue = value,
IsNew = false
});
}
var section = FormatSection(merged);
var markdown = ReplaceSection(existingMarkdown, section);
return new ImportedFieldsMergeResult
{
Markdown = markdown,
Changed = changes.Count > 0,
Changes = changes
};
}
public static string FormatSection(IReadOnlyList<ImportedField> fields)
{
var builder = new StringBuilder();
builder.AppendLine(Heading);
builder.AppendLine();
builder.AppendLine("| Field | Value |");
builder.AppendLine("| --- | --- |");
foreach (var field in fields)
builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |");
return builder.ToString().TrimEnd() + Environment.NewLine;
}
public static string? GetFieldValue(string? markdown, string fieldName)
{
var field = ParseFields(markdown)
.FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
return field?.Value;
}
/// <summary>
/// Unique imported field names across notes, first-seen casing, sorted A–Z.
/// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
Dictionary<string, string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (var markdown in markdowns)
{
foreach (var field in ParseFields(markdown))
{
var name = field.Name.Trim();
if (name.Length == 0)
continue;
names.TryAdd(name, name);
}
}
return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
}
private static string? ExtractSection(string? markdown)
{
if (string.IsNullOrEmpty(markdown))
return null;
var start = IndexOfHeading(markdown);
if (start < 0)
return null;
var afterHeading = start + Heading.Length;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
private static string ReplaceSection(string? existingMarkdown, string section)
{
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
var start = IndexOfHeading(existingMarkdown);
if (start < 0)
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
? section
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + Heading.Length;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(before))
{
builder.Append(before);
builder.AppendLine();
builder.AppendLine();
}
builder.Append(section.TrimEnd());
builder.AppendLine();
if (!string.IsNullOrEmpty(after))
{
builder.AppendLine();
builder.Append(after.TrimEnd());
builder.AppendLine();
}
return builder.ToString();
}
private static int IndexOfHeading(string markdown) =>
markdown.IndexOf(Heading, StringComparison.Ordinal);
private static int FindNextHeading(string markdown, int startIndex)
{
var index = startIndex;
while (index < markdown.Length)
{
var lineStart = markdown.IndexOf('\n', index);
if (lineStart < 0)
return -1;
lineStart++;
if (lineStart < markdown.Length && markdown[lineStart] == '#' &&
lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' &&
markdown[lineStart + 2] == ' ')
return lineStart;
index = lineStart;
}
return -1;
}
/// <summary>
/// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
/// value (e.g. <c>| Teacher Rec 3 | |</c>) is kept instead of dropped.
/// </summary>
private static string[] SplitTableCells(string line)
{
var parts = line.Split('|', StringSplitOptions.TrimEntries);
var start = 0;
var length = parts.Length;
if (length > 0 && parts[0].Length == 0)
{
start = 1;
length--;
}
if (length > 0 && parts[start + length - 1].Length == 0)
length--;
return length <= 0 ? [] : parts[start..(start + length)];
}
private static string EscapeCell(string value) => value.Replace("|", "\\|");
private static string UnescapeCell(string value) => value.Replace("\\|", "|");
}
public record ImportedField(string Name, string Value);
public class ImportedFieldsMergeResult
{
public required string Markdown { get; init; }
public bool Changed { get; init; }
public List<ImportedFieldChange> Changes { get; init; } = [];
}
public class ImportedFieldChange
{
public required string Field { get; init; }
public string? PreviousValue { get; init; }
public required string NewValue { get; init; }
public bool IsNew { get; init; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace Core.Notes;
/// <summary>
/// Default imported-field names used for Students index columns and the CSV template.
/// </summary>
public static class StudentNoteFieldDefaults
{
public static readonly string[] IndexColumns =
[
"Interview Time",
"Application",
"Club Permission Slip"
];
}
+29
View File
@@ -0,0 +1,29 @@
using Core.Entities;
using FuzzySharp;
namespace Core.Parsers;
/// <summary>
/// Fuzzy-matches a CSV or pasted name to existing students.
/// </summary>
public static class FuzzyStudentMatcher
{
public const int MatchThreshold = 90;
public static (Student Student, int Score)? Find(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: Score(s, name)))
.Where(x => x.Score >= MatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
public static int Score(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
}
+1 -19
View File
@@ -6,7 +6,6 @@ namespace Core.Parsers;
public class StudentEventRankingParser : CsvParserBase
{
public const int StudentMatchThreshold = 90;
public const int EventMatchThreshold = 70;
public const int EventAmbiguityGap = 8;
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FindStudent(students, name);
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentEventRankingIssue
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
return result;
}
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
.Where(x => x.Score >= StudentMatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
private static int ScoreStudent(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
{
var scored = events
+63
View File
@@ -0,0 +1,63 @@
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Builds a starter CSV for <c>/students/import</c> with roster columns plus leftover note fields.
/// </summary>
public static class StudentImportCsvTemplate
{
public static readonly string[] RosterHeaders =
[
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID"
];
/// <summary>
/// Returns a CSV with a header row and one example data row.
/// </summary>
public static string Build(IEnumerable<string>? leftoverFieldNames = null)
{
List<string> leftovers = leftoverFieldNames?
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Where(name => !StudentNotesFieldParser.IsReservedHeader(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
if (leftovers.Count == 0)
leftovers = [.. StudentNoteFieldDefaults.IndexColumns];
var headers = RosterHeaders.Concat(leftovers).ToArray();
var values = headers.Select(ExampleValue).ToArray();
return $"{ToCsvRow(headers)}{Environment.NewLine}{ToCsvRow(values)}{Environment.NewLine}";
}
private static string ExampleValue(string header) => header switch
{
"Student Name" => "Last, First",
"Grade" => "9",
"TSA year" => "1st",
"Interview Time" => "3:20-3:35",
_ when ContainsIgnoreCase(header, "Application")
|| ContainsIgnoreCase(header, "Permission") => "x",
_ => string.Empty
};
private static bool ContainsIgnoreCase(string value, string part) =>
value.Contains(part, StringComparison.OrdinalIgnoreCase);
private static string ToCsvRow(IEnumerable<string> cells) =>
string.Join(",", cells.Select(EscapeCsv));
private static string EscapeCsv(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+164
View File
@@ -0,0 +1,164 @@
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);
}
}
+15
View File
@@ -35,4 +35,19 @@ public interface INoteNamingService
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle);
/// <summary>
/// Gets the title for a student note. Format: "#Student:{id}"
/// </summary>
string GetStudentNoteTitle(int studentId);
/// <summary>
/// Checks if a note title is a student note.
/// </summary>
bool IsStudentNote(string? noteTitle);
/// <summary>
/// Parses the student id from a student note title.
/// </summary>
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
}
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student field CSVs and merges them into markdown notes.
/// </summary>
public interface IStudentNotesImportService
{
StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
}
+23
View File
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
private const string StudentNotePrefix = "#Student:";
/// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
/// <inheritdoc/>
public bool IsStudentNote(string? noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
return false;
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
{
studentId = 0;
if (!IsStudentNote(noteTitle))
return false;
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
}
}
+54
View File
@@ -0,0 +1,54 @@
using Core.Models;
namespace Core.Services;
/// <summary>
/// Decides which parsed student notes should be created or updated.
/// Unchanged merges are omitted so a re-import does not write history.
/// </summary>
public static class StudentNotesImportPlan
{
/// <summary>
/// Builds persist actions from a parse result. Only matches with field changes are included.
/// </summary>
public static IReadOnlyList<StudentNotePersistAction> Create(
StudentNotesImportResult parseResult,
IReadOnlySet<int> studentIdsWithExistingNotes)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
List<StudentNotePersistAction> actions = [];
foreach (var match in parseResult.Matches)
{
if (!match.Merge.Changed)
continue;
actions.Add(new StudentNotePersistAction
{
StudentId = match.Student.Id,
Markdown = match.Merge.Markdown,
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
? StudentNotePersistKind.Update
: StudentNotePersistKind.Create
});
}
return actions;
}
}
public enum StudentNotePersistKind
{
Create,
Update
}
public class StudentNotePersistAction
{
public required int StudentId { get; init; }
public required string Markdown { get; init; }
public required StudentNotePersistKind Kind { get; init; }
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
/// </summary>
public class StudentNotesImportService : IStudentNotesImportService
{
/// <inheritdoc />
public StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, existingNotesByStudentId);
}
}