diff --git a/Core/Models/StudentNotesImportResult.cs b/Core/Models/StudentNotesImportResult.cs
new file mode 100644
index 0000000..f8f7250
--- /dev/null
+++ b/Core/Models/StudentNotesImportResult.cs
@@ -0,0 +1,48 @@
+using Core.Entities;
+using Core.Notes;
+
+namespace Core.Models;
+
+///
+/// Result of parsing a student notes field CSV.
+///
+public class StudentNotesImportResult
+{
+ public List Matches { get; set; } = [];
+
+ public List Issues { get; set; } = [];
+
+ public List Errors { get; set; } = [];
+
+ public List Warnings { get; set; } = [];
+
+ public IReadOnlyList 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 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;
+}
diff --git a/Core/Notes/ImportedFieldsTable.cs b/Core/Notes/ImportedFieldsTable.cs
new file mode 100644
index 0000000..bfd700e
--- /dev/null
+++ b/Core/Notes/ImportedFieldsTable.cs
@@ -0,0 +1,272 @@
+using System.Text;
+
+namespace Core.Notes;
+
+///
+/// Parses and upserts a generic Field/Value markdown table under a stable heading.
+///
+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;
+ }
+
+ ///
+ /// Reads Field/Value rows from the Imported fields section.
+ ///
+ public static List ParseFields(string? markdown)
+ {
+ var section = ExtractSection(markdown);
+ if (string.IsNullOrWhiteSpace(section))
+ return [];
+
+ List 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;
+ }
+
+ ///
+ /// Upserts incoming fields into the Imported fields section. Incoming values win.
+ /// Fields not in are kept. Identical values are not changes.
+ ///
+ public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList incoming)
+ {
+ var existing = ParseFields(existingMarkdown);
+ var merged = existing.ToList();
+ List 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 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;
+ }
+
+ ///
+ /// Unique imported field names across notes, first-seen casing, sorted A–Z.
+ ///
+ public static List DistinctFieldNames(IEnumerable markdowns)
+ {
+ Dictionary 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;
+ }
+
+ ///
+ /// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
+ /// value (e.g. | Teacher Rec 3 | |) is kept instead of dropped.
+ ///
+ 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 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; }
+}
diff --git a/Core/Notes/StudentNoteFieldDefaults.cs b/Core/Notes/StudentNoteFieldDefaults.cs
new file mode 100644
index 0000000..1cd2960
--- /dev/null
+++ b/Core/Notes/StudentNoteFieldDefaults.cs
@@ -0,0 +1,14 @@
+namespace Core.Notes;
+
+///
+/// Default imported-field names used for Students index columns and the CSV template.
+///
+public static class StudentNoteFieldDefaults
+{
+ public static readonly string[] IndexColumns =
+ [
+ "Interview Time",
+ "Application",
+ "Club Permission Slip"
+ ];
+}
diff --git a/Core/Parsers/FuzzyStudentMatcher.cs b/Core/Parsers/FuzzyStudentMatcher.cs
new file mode 100644
index 0000000..43051ea
--- /dev/null
+++ b/Core/Parsers/FuzzyStudentMatcher.cs
@@ -0,0 +1,29 @@
+using Core.Entities;
+using FuzzySharp;
+
+namespace Core.Parsers;
+
+///
+/// Fuzzy-matches a CSV or pasted name to existing students.
+///
+public static class FuzzyStudentMatcher
+{
+ public const int MatchThreshold = 90;
+
+ public static (Student Student, int Score)? Find(ICollection 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)));
+ }
+}
diff --git a/Core/Parsers/StudentEventRankingParser.cs b/Core/Parsers/StudentEventRankingParser.cs
index 0ffc85f..290ef6b 100644
--- a/Core/Parsers/StudentEventRankingParser.cs
+++ b/Core/Parsers/StudentEventRankingParser.cs
@@ -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 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 events, string eventName)
{
var scored = events
diff --git a/Core/Parsers/StudentImportCsvTemplate.cs b/Core/Parsers/StudentImportCsvTemplate.cs
new file mode 100644
index 0000000..e99a3f1
--- /dev/null
+++ b/Core/Parsers/StudentImportCsvTemplate.cs
@@ -0,0 +1,63 @@
+using Core.Notes;
+
+namespace Core.Parsers;
+
+///
+/// Builds a starter CSV for /students/import with roster columns plus leftover note fields.
+///
+public static class StudentImportCsvTemplate
+{
+ public static readonly string[] RosterHeaders =
+ [
+ "Student Name",
+ "Grade",
+ "TSA year",
+ "State ID",
+ "Regional ID",
+ "National ID"
+ ];
+
+ ///
+ /// Returns a CSV with a header row and one example data row.
+ ///
+ public static string Build(IEnumerable? leftoverFieldNames = null)
+ {
+ List 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 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;
+ }
+}
diff --git a/Core/Parsers/StudentNotesFieldParser.cs b/Core/Parsers/StudentNotesFieldParser.cs
new file mode 100644
index 0000000..f0554a9
--- /dev/null
+++ b/Core/Parsers/StudentNotesFieldParser.cs
@@ -0,0 +1,164 @@
+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);
+ }
+}
diff --git a/Core/Services/INoteNamingService.cs b/Core/Services/INoteNamingService.cs
index ff37eb9..291e5a8 100644
--- a/Core/Services/INoteNamingService.cs
+++ b/Core/Services/INoteNamingService.cs
@@ -35,4 +35,19 @@ public interface INoteNamingService
/// The note title to check
/// True if the note is a meeting note, false otherwise
bool IsMeetingNote(string noteTitle);
+
+ ///
+ /// Gets the title for a student note. Format: "#Student:{id}"
+ ///
+ string GetStudentNoteTitle(int studentId);
+
+ ///
+ /// Checks if a note title is a student note.
+ ///
+ bool IsStudentNote(string? noteTitle);
+
+ ///
+ /// Parses the student id from a student note title.
+ ///
+ bool TryParseStudentNoteId(string? noteTitle, out int studentId);
}
diff --git a/Core/Services/IStudentNotesImportService.cs b/Core/Services/IStudentNotesImportService.cs
new file mode 100644
index 0000000..ba676c7
--- /dev/null
+++ b/Core/Services/IStudentNotesImportService.cs
@@ -0,0 +1,15 @@
+using Core.Entities;
+using Core.Models;
+
+namespace Core.Services;
+
+///
+/// Parses student field CSVs and merges them into markdown notes.
+///
+public interface IStudentNotesImportService
+{
+ StudentNotesImportResult Parse(
+ Stream stream,
+ ICollection students,
+ IReadOnlyDictionary existingNotesByStudentId);
+}
diff --git a/Core/Services/NoteNamingService.cs b/Core/Services/NoteNamingService.cs
index 7ca3ca5..86bf04c 100644
--- a/Core/Services/NoteNamingService.cs
+++ b/Core/Services/NoteNamingService.cs
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
+ private const string StudentNotePrefix = "#Student:";
///
public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
+
+ ///
+ public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
+
+ ///
+ public bool IsStudentNote(string? noteTitle)
+ {
+ if (string.IsNullOrWhiteSpace(noteTitle))
+ return false;
+
+ return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
+ }
+
+ ///
+ public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
+ {
+ studentId = 0;
+ if (!IsStudentNote(noteTitle))
+ return false;
+
+ return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
+ }
}
diff --git a/Core/Services/StudentNotesImportPlan.cs b/Core/Services/StudentNotesImportPlan.cs
new file mode 100644
index 0000000..c12e8f2
--- /dev/null
+++ b/Core/Services/StudentNotesImportPlan.cs
@@ -0,0 +1,54 @@
+using Core.Models;
+
+namespace Core.Services;
+
+///
+/// Decides which parsed student notes should be created or updated.
+/// Unchanged merges are omitted so a re-import does not write history.
+///
+public static class StudentNotesImportPlan
+{
+ ///
+ /// Builds persist actions from a parse result. Only matches with field changes are included.
+ ///
+ public static IReadOnlyList Create(
+ StudentNotesImportResult parseResult,
+ IReadOnlySet studentIdsWithExistingNotes)
+ {
+ ArgumentNullException.ThrowIfNull(parseResult);
+ ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
+
+ List 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; }
+}
diff --git a/Core/Services/StudentNotesImportService.cs b/Core/Services/StudentNotesImportService.cs
new file mode 100644
index 0000000..f23587c
--- /dev/null
+++ b/Core/Services/StudentNotesImportService.cs
@@ -0,0 +1,22 @@
+using Core.Entities;
+using Core.Models;
+using Core.Parsers;
+
+namespace Core.Services;
+
+///
+/// Wraps for stream-based import.
+///
+public class StudentNotesImportService : IStudentNotesImportService
+{
+ ///
+ public StudentNotesImportResult Parse(
+ Stream stream,
+ ICollection students,
+ IReadOnlyDictionary existingNotesByStudentId)
+ {
+ var reader = new StreamReader(stream, leaveOpen: true);
+ using var parser = new StudentNotesFieldParser(reader);
+ return parser.Parse(students, existingNotesByStudentId);
+ }
+}
diff --git a/Tests/Notes/ImportedFieldsTable_Tests.cs b/Tests/Notes/ImportedFieldsTable_Tests.cs
new file mode 100644
index 0000000..baac530
--- /dev/null
+++ b/Tests/Notes/ImportedFieldsTable_Tests.cs
@@ -0,0 +1,179 @@
+using Core.Notes;
+
+namespace Tests.Notes;
+
+[TestFixture]
+public class ImportedFieldsTable_Tests
+{
+ [Test]
+ public void NormalizeValue_X_BecomesYes()
+ {
+ Assert.That(ImportedFieldsTable.NormalizeValue("x"), Is.EqualTo("Yes"));
+ Assert.That(ImportedFieldsTable.NormalizeValue("X"), Is.EqualTo("Yes"));
+ Assert.That(ImportedFieldsTable.NormalizeValue(" x "), Is.EqualTo("Yes"));
+ }
+
+ [Test]
+ public void NormalizeValue_Blank_StaysEmpty()
+ {
+ Assert.That(ImportedFieldsTable.NormalizeValue(null), Is.EqualTo(string.Empty));
+ Assert.That(ImportedFieldsTable.NormalizeValue(" "), Is.EqualTo(string.Empty));
+ }
+
+ [Test]
+ public void Merge_IncomingWins_AndAppendsNewFields()
+ {
+ var existing = """
+ Freeform note
+
+ ## Imported fields
+
+ | Field | Value |
+ | --- | --- |
+ | Interview Time | 1:00-1:15 |
+ | Application | Yes |
+ """;
+
+ var result = ImportedFieldsTable.Merge(existing,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Teacher Rec 1", "Fuqua")
+ ]);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(result.Changes, Has.Count.EqualTo(2));
+ var fields = ImportedFieldsTable.ParseFields(result.Markdown);
+ Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
+ Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
+ Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
+ Assert.That(result.Markdown, Does.Contain("Freeform note"));
+ }
+
+ [Test]
+ public void Merge_SameDataTwice_IsNoOp()
+ {
+ var first = ImportedFieldsTable.Merge(null,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Application", "x")
+ ]);
+
+ Assert.That(first.Changed, Is.True);
+ Assert.That(ImportedFieldsTable.ParseFields(first.Markdown).Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
+
+ var second = ImportedFieldsTable.Merge(first.Markdown,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Application", "x")
+ ]);
+
+ Assert.That(second.Changed, Is.False);
+ Assert.That(second.Changes, Is.Empty);
+ Assert.That(ImportedFieldsTable.ParseFields(second.Markdown), Is.EqualTo(ImportedFieldsTable.ParseFields(first.Markdown)));
+ }
+
+ [Test]
+ public void GetFieldValue_ReadsImportedTable()
+ {
+ var markdown = ImportedFieldsTable.FormatSection(
+ [
+ new ImportedField("Interview Time", "3:20-3:35")
+ ]);
+
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Interview Time"), Is.EqualTo("3:20-3:35"));
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.Null);
+ }
+
+ [Test]
+ public void Merge_PreservesTextAfterSection()
+ {
+ var existing = """
+ ## Imported fields
+
+ | Field | Value |
+ | --- | --- |
+ | Application | Yes |
+
+ ## Advisor comments
+
+ Great interview.
+ """;
+
+ var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Application", "Yes")]);
+ Assert.That(result.Changed, Is.False);
+ Assert.That(result.Markdown, Does.Contain("## Advisor comments"));
+ Assert.That(result.Markdown, Does.Contain("Great interview."));
+ }
+
+ [Test]
+ public void ParseFields_KeepsBlankValues()
+ {
+ var markdown = ImportedFieldsTable.FormatSection(
+ [
+ new ImportedField("Application", "Yes"),
+ new ImportedField("Teacher Rec 3", "")
+ ]);
+
+ var fields = ImportedFieldsTable.ParseFields(markdown);
+ Assert.That(fields.Single(f => f.Name == "Teacher Rec 3").Value, Is.EqualTo(string.Empty));
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
+ }
+
+ [Test]
+ public void Merge_BlankFields_SameDataTwice_IsNoOp()
+ {
+ var first = ImportedFieldsTable.Merge(null,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Teacher Rec 3", "")
+ ]);
+
+ Assert.That(first.Changed, Is.True);
+ Assert.That(ImportedFieldsTable.ParseFields(first.Markdown), Has.Count.EqualTo(2));
+
+ var second = ImportedFieldsTable.Merge(first.Markdown,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Teacher Rec 3", "")
+ ]);
+
+ Assert.That(second.Changed, Is.False);
+ Assert.That(second.Changes, Is.Empty);
+ }
+
+ [Test]
+ public void Merge_IncomingBlank_ClearsExistingValue()
+ {
+ var existing = ImportedFieldsTable.Merge(null, [new ImportedField("Teacher Rec 1", "Fuqua")]).Markdown;
+ var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Teacher Rec 1", "")]);
+
+ Assert.That(result.Changed, Is.True);
+ Assert.That(ImportedFieldsTable.GetFieldValue(result.Markdown, "Teacher Rec 1"), Is.EqualTo(string.Empty));
+ }
+
+ [Test]
+ public void GetFieldValue_IsCaseInsensitive()
+ {
+ var markdown = ImportedFieldsTable.FormatSection([new ImportedField("Interview Time", "3:20-3:35")]);
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "interview time"), Is.EqualTo("3:20-3:35"));
+ }
+
+ [Test]
+ public void DistinctFieldNames_UnionsNotes_AndKeepsFirstCasing()
+ {
+ var first = ImportedFieldsTable.FormatSection(
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Application", "Yes")
+ ]);
+ var second = ImportedFieldsTable.FormatSection(
+ [
+ new ImportedField("application", "Yes"),
+ new ImportedField("Teacher Rec 1", "Fuqua")
+ ]);
+
+ var names = ImportedFieldsTable.DistinctFieldNames([first, second, null, ""]);
+
+ Assert.That(names, Is.EqualTo(new[] { "Application", "Interview Time", "Teacher Rec 1" }));
+ }
+}
diff --git a/Tests/Parsers/StudentImportCsvTemplate_Tests.cs b/Tests/Parsers/StudentImportCsvTemplate_Tests.cs
new file mode 100644
index 0000000..ec3bf75
--- /dev/null
+++ b/Tests/Parsers/StudentImportCsvTemplate_Tests.cs
@@ -0,0 +1,30 @@
+using Core.Parsers;
+
+namespace Tests.Parsers;
+
+[TestFixture]
+public class StudentImportCsvTemplate_Tests
+{
+ [Test]
+ public void Build_IncludesRosterAndDefaultLeftoverColumns()
+ {
+ var csv = StudentImportCsvTemplate.Build();
+ var header = csv.Split('\n')[0].TrimEnd('\r');
+
+ Assert.That(header, Does.StartWith("Student Name,Grade,TSA year,State ID,Regional ID,National ID"));
+ Assert.That(header, Does.Contain("Interview Time"));
+ Assert.That(header, Does.Contain("Application"));
+ Assert.That(csv, Does.Contain("\"Last, First\""));
+ }
+
+ [Test]
+ public void Build_UsesConfiguredLeftoverFields_AndSkipsReserved()
+ {
+ var csv = StudentImportCsvTemplate.Build(["Teacher Rec 1", "Grade", " Application "]);
+ var header = csv.Split('\n')[0].TrimEnd('\r');
+
+ Assert.That(header, Does.Contain("Teacher Rec 1"));
+ Assert.That(header, Does.Contain("Application"));
+ Assert.That(header.Split(',').Count(c => c.Equals("Grade", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1));
+ }
+}
diff --git a/Tests/Parsers/StudentNotesFieldParser_Tests.cs b/Tests/Parsers/StudentNotesFieldParser_Tests.cs
new file mode 100644
index 0000000..5a38330
--- /dev/null
+++ b/Tests/Parsers/StudentNotesFieldParser_Tests.cs
@@ -0,0 +1,196 @@
+using System.Text;
+using Core.Notes;
+using Core.Parsers;
+using Tests.Builders;
+
+namespace Tests.Parsers;
+
+[TestFixture]
+public class StudentNotesFieldParser_Tests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ BuilderExtensions.ResetAllBuilders();
+ }
+
+ [Test]
+ public void Parse_MatchesStudentAndReportsUnmatched()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name,Interview Time,Application
+ "Chittenden, Aria",3:20-3:35,x
+ Nobody Here,1:00-1:15,x
+ """;
+
+ var result = Parse(csv, [aria]);
+
+ Assert.That(result.IsSuccess, Is.True);
+ Assert.That(result.Matches, Has.Count.EqualTo(1));
+ Assert.That(result.Matches[0].Student, Is.SameAs(aria));
+ Assert.That(result.Matches[0].Merge.Changed, Is.True);
+ Assert.That(result.Issues, Has.Count.EqualTo(1));
+ Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
+ }
+
+ [Test]
+ public void Parse_SameExistingNote_HasNoChanges()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ var existing = ImportedFieldsTable.Merge(null,
+ [
+ new ImportedField("Interview Time", "3:20-3:35"),
+ new ImportedField("Application", "Yes")
+ ]).Markdown;
+
+ const string csv = """
+ Student Name,Interview Time,Application
+ Aria Chittenden,3:20-3:35,x
+ """;
+
+ var result = Parse(csv, [aria], new Dictionary { [aria.Id] = existing });
+
+ Assert.That(result.Matches, Has.Count.EqualTo(1));
+ Assert.That(result.Matches[0].Merge.Changed, Is.False);
+ Assert.That(result.StudentsWithChanges, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Parse_MissingStudentNameColumn_IsError()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Name,Interview Time
+ Aria Chittenden,3:20-3:35
+ """;
+
+ var result = Parse(csv, [aria]);
+
+ Assert.That(result.IsSuccess, Is.False);
+ Assert.That(result.Errors, Has.Some.Contains("Student Name"));
+ Assert.That(result.Matches, Is.Empty);
+ }
+
+ [Test]
+ public void Parse_PreservesFieldsNotInCsv()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ var existing = ImportedFieldsTable.Merge(null,
+ [
+ new ImportedField("Interview Time", "1:00-1:15"),
+ new ImportedField("Teacher Rec 1", "Fuqua")
+ ]).Markdown;
+
+ const string csv = """
+ Student Name,Interview Time
+ Aria Chittenden,3:20-3:35
+ """;
+
+ var result = Parse(csv, [aria], new Dictionary { [aria.Id] = existing });
+ var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
+
+ Assert.That(result.Matches[0].Merge.Changed, Is.True);
+ Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
+ Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
+ }
+
+ [Test]
+ public void Parse_X_BecomesYes_AndBlankFieldsRoundTrip()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name,Application,Teacher Rec 3
+ Aria Chittenden,x,
+ """;
+
+ var first = Parse(csv, [aria]);
+ var markdown = first.Matches[0].Merge.Markdown;
+
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.EqualTo("Yes"));
+ Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
+
+ var second = Parse(csv, [aria], new Dictionary { [aria.Id] = markdown });
+ Assert.That(second.Matches[0].Merge.Changed, Is.False);
+ Assert.That(second.StudentsWithChanges, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Parse_DuplicateStudentRows_LastValueWins_OneMatch()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name,Interview Time,Teacher Rec 1
+ Aria Chittenden,1:00-1:15,Fuqua
+ "Chittenden, Aria",3:20-3:35,Young
+ """;
+
+ var result = Parse(csv, [aria]);
+ var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
+
+ Assert.That(result.Matches, Has.Count.EqualTo(1));
+ Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
+ Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Young"));
+ }
+
+ [Test]
+ public void Parse_NameOnlyCsv_WarnsAndMatchesWithoutFieldChanges()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name
+ Aria Chittenden
+ """;
+
+ var result = Parse(csv, [aria]);
+
+ Assert.That(result.IsSuccess, Is.True);
+ Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
+ Assert.That(result.Matches, Has.Count.EqualTo(1));
+ Assert.That(result.Matches[0].Merge.Changed, Is.False);
+ }
+
+ [Test]
+ public void Parse_ExcludesRosterAndRankingColumns()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name,Grade,TSA year,State ID,Regional ID,National ID,Officer,1,2,TOTAL # OF EVENTS,Interview Time,Application
+ Aria Chittenden,6,1st,,,,,Coding,JSS,2,3:20-3:35,x
+ """;
+
+ var result = Parse(csv, [aria]);
+ var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
+
+ Assert.That(result.FieldNames, Is.EquivalentTo(["Interview Time", "Application"]));
+ Assert.That(fields.Select(f => f.Name), Is.EquivalentTo(["Interview Time", "Application"]));
+ Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
+ Assert.That(fields.Any(f => f.Name is "Grade" or "1" or "Officer"), Is.False);
+ }
+
+ [Test]
+ public void Parse_RosterOnlyCsv_HasNoLeftoverFields()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ const string csv = """
+ Student Name,Grade,TSA year,State ID,Regional ID,National ID
+ Aria Chittenden,6,1st,,,
+ """;
+
+ var result = Parse(csv, [aria]);
+
+ Assert.That(result.FieldNames, Is.Empty);
+ Assert.That(result.Matches[0].Merge.Changed, Is.False);
+ Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
+ }
+
+ private static Core.Models.StudentNotesImportResult Parse(
+ string csv,
+ ICollection students,
+ IReadOnlyDictionary? notes = null)
+ {
+ using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
+ using var parser = new StudentNotesFieldParser(reader);
+ return parser.Parse(students, notes ?? new Dictionary());
+ }
+}
diff --git a/Tests/Services/NoteNamingService_Tests.cs b/Tests/Services/NoteNamingService_Tests.cs
new file mode 100644
index 0000000..1e8ad73
--- /dev/null
+++ b/Tests/Services/NoteNamingService_Tests.cs
@@ -0,0 +1,28 @@
+using Core.Services;
+
+namespace Tests.Services;
+
+[TestFixture]
+public class NoteNamingService_Tests
+{
+ private readonly NoteNamingService _service = new();
+
+ [Test]
+ public void GetStudentNoteTitle_UsesStablePrefix()
+ {
+ Assert.That(_service.GetStudentNoteTitle(42), Is.EqualTo("#Student:42"));
+ }
+
+ [Test]
+ public void IsStudentNote_AndParseId()
+ {
+ Assert.That(_service.IsStudentNote("#Student:12"), Is.True);
+ Assert.That(_service.IsStudentNote("#Students"), Is.False);
+ Assert.That(_service.TryParseStudentNoteId("#Student:12", out var id), Is.True);
+ Assert.That(id, Is.EqualTo(12));
+ Assert.That(_service.TryParseStudentNoteId("#Event Ranking", out _), Is.False);
+ Assert.That(_service.IsStudentNote(null), Is.False);
+ Assert.That(_service.TryParseStudentNoteId("#Student:", out _), Is.False);
+ Assert.That(_service.IsPageNote("#Student:12"), Is.True);
+ }
+}
diff --git a/Tests/Services/StudentNotesImportPlan_Tests.cs b/Tests/Services/StudentNotesImportPlan_Tests.cs
new file mode 100644
index 0000000..047eb87
--- /dev/null
+++ b/Tests/Services/StudentNotesImportPlan_Tests.cs
@@ -0,0 +1,64 @@
+using Core.Models;
+using Core.Notes;
+using Core.Services;
+using Tests.Builders;
+
+namespace Tests.Services;
+
+[TestFixture]
+public class StudentNotesImportPlan_Tests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ BuilderExtensions.ResetAllBuilders();
+ }
+
+ [Test]
+ public void Create_SkipsUnchanged_CreatesAndUpdatesChanged()
+ {
+ var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
+ var blake = StudentBuilder.Create("Blake", "Nguyen").Build();
+ var casey = StudentBuilder.Create("Casey", "Ortiz").Build();
+
+ var firstWrite = ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]);
+ var unchanged = ImportedFieldsTable.Merge(firstWrite.Markdown, [new ImportedField("Application", "Yes")]);
+ var created = ImportedFieldsTable.Merge(null, [new ImportedField("Interview Time", "3:20-3:35")]);
+ var updated = ImportedFieldsTable.Merge(
+ ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]).Markdown,
+ [new ImportedField("Application", "")]);
+
+ var parseResult = new StudentNotesImportResult
+ {
+ Matches =
+ [
+ Match(aria, unchanged),
+ Match(blake, created),
+ Match(casey, updated)
+ ]
+ };
+
+ HashSet existingNoteIds = [casey.Id];
+ var actions = StudentNotesImportPlan.Create(parseResult, existingNoteIds);
+
+ Assert.That(actions, Has.Count.EqualTo(2));
+ Assert.That(actions.Any(a => a.StudentId == aria.Id), Is.False);
+ Assert.That(actions.Single(a => a.StudentId == blake.Id).Kind, Is.EqualTo(StudentNotePersistKind.Create));
+ Assert.That(actions.Single(a => a.StudentId == casey.Id).Kind, Is.EqualTo(StudentNotePersistKind.Update));
+ Assert.That(actions.Single(a => a.StudentId == casey.Id).Markdown, Is.EqualTo(updated.Markdown));
+ }
+
+ [Test]
+ public void Create_EmptyMatches_ReturnsNoActions()
+ {
+ var actions = StudentNotesImportPlan.Create(new StudentNotesImportResult(), new HashSet());
+ Assert.That(actions, Is.Empty);
+ }
+
+ private static StudentNotesImportMatch Match(Core.Entities.Student student, ImportedFieldsMergeResult merge) =>
+ new()
+ {
+ Student = student,
+ Merge = merge
+ };
+}
diff --git a/WebApp/Components/App.razor b/WebApp/Components/App.razor
index aec7b52..a5ab731 100644
--- a/WebApp/Components/App.razor
+++ b/WebApp/Components/App.razor
@@ -25,6 +25,7 @@
+