From 4cfd85b902546b4f029da6828b372f57313f4009 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sat, 29 Aug 2026 23:55:03 -0400 Subject: [PATCH] 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 --- Core/Models/StudentNotesImportResult.cs | 48 +++ Core/Notes/ImportedFieldsTable.cs | 272 ++++++++++++++++ Core/Notes/StudentNoteFieldDefaults.cs | 14 + Core/Parsers/FuzzyStudentMatcher.cs | 29 ++ Core/Parsers/StudentEventRankingParser.cs | 20 +- Core/Parsers/StudentImportCsvTemplate.cs | 63 ++++ Core/Parsers/StudentNotesFieldParser.cs | 164 ++++++++++ Core/Services/INoteNamingService.cs | 15 + Core/Services/IStudentNotesImportService.cs | 15 + Core/Services/NoteNamingService.cs | 23 ++ Core/Services/StudentNotesImportPlan.cs | 54 ++++ Core/Services/StudentNotesImportService.cs | 22 ++ Tests/Notes/ImportedFieldsTable_Tests.cs | 179 +++++++++++ .../Parsers/StudentImportCsvTemplate_Tests.cs | 30 ++ .../Parsers/StudentNotesFieldParser_Tests.cs | 196 ++++++++++++ Tests/Services/NoteNamingService_Tests.cs | 28 ++ .../Services/StudentNotesImportPlan_Tests.cs | 64 ++++ WebApp/Components/App.razor | 1 + .../Features/Events/EventCatalogImport.razor | 253 +++++++++++++++ WebApp/Components/Features/Events/Index.razor | 6 + .../Components/StudentNotePanel.razor | 104 ++++++ .../Features/Students/Details.razor | 4 + .../Components/Features/Students/Edit.razor | 8 + .../Components/Features/Students/Index.razor | 83 +++++ .../Features/Students/StudentImport.razor | 301 ++++++++++++++++++ WebApp/Components/Pages/ChapterSettings.razor | 110 ++++++- WebApp/Components/Pages/Import.razor | 115 ------- WebApp/Models/ChapterSettings.cs | 24 ++ WebApp/Program.cs | 17 +- WebApp/Services/ChapterSettingsWriter.cs | 34 +- WebApp/Services/INotesService.cs | 20 ++ .../IStudentNotesImportSaveService.cs | 22 ++ WebApp/Services/NotesService.cs | 90 +++++- .../Services/StudentNotesImportSaveService.cs | 64 ++++ WebApp/Services/YearRolloverService.cs | 5 + WebApp/wwwroot/js/downloadFile.js | 21 ++ docs/instructions/event-catalog-import.md | 23 ++ docs/instructions/student-notes-import.md | 50 +++ docs/instructions/year-rollover.md | 12 +- 39 files changed, 2437 insertions(+), 166 deletions(-) create mode 100644 Core/Models/StudentNotesImportResult.cs create mode 100644 Core/Notes/ImportedFieldsTable.cs create mode 100644 Core/Notes/StudentNoteFieldDefaults.cs create mode 100644 Core/Parsers/FuzzyStudentMatcher.cs create mode 100644 Core/Parsers/StudentImportCsvTemplate.cs create mode 100644 Core/Parsers/StudentNotesFieldParser.cs create mode 100644 Core/Services/IStudentNotesImportService.cs create mode 100644 Core/Services/StudentNotesImportPlan.cs create mode 100644 Core/Services/StudentNotesImportService.cs create mode 100644 Tests/Notes/ImportedFieldsTable_Tests.cs create mode 100644 Tests/Parsers/StudentImportCsvTemplate_Tests.cs create mode 100644 Tests/Parsers/StudentNotesFieldParser_Tests.cs create mode 100644 Tests/Services/NoteNamingService_Tests.cs create mode 100644 Tests/Services/StudentNotesImportPlan_Tests.cs create mode 100644 WebApp/Components/Features/Events/EventCatalogImport.razor create mode 100644 WebApp/Components/Features/Students/Components/StudentNotePanel.razor create mode 100644 WebApp/Components/Features/Students/StudentImport.razor delete mode 100644 WebApp/Components/Pages/Import.razor create mode 100644 WebApp/Services/IStudentNotesImportSaveService.cs create mode 100644 WebApp/Services/StudentNotesImportSaveService.cs create mode 100644 WebApp/wwwroot/js/downloadFile.js create mode 100644 docs/instructions/event-catalog-import.md create mode 100644 docs/instructions/student-notes-import.md 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 @@ + diff --git a/WebApp/Components/Features/Events/EventCatalogImport.razor b/WebApp/Components/Features/Events/EventCatalogImport.razor new file mode 100644 index 0000000..92568cc --- /dev/null +++ b/WebApp/Components/Features/Events/EventCatalogImport.razor @@ -0,0 +1,253 @@ +@page "/events/import" +@attribute [Authorize(Roles = AuthRoles.Administrator)] +@implements IAsyncDisposable +@using Core.Parsers +@using Microsoft.EntityFrameworkCore +@using WebApp.Authentication +@using WebApp.Models +@inject AppDbContext Context +@inject NavigationManager NavigationManager +@inject ISnackbar Snackbar +@inject ILogger Logger +@rendermode InteractiveServer + + + + + + + Upload CSV + + + Required columns: Event, Team Size, State Count. + Optional: Short Name, EventFormat, Level of Effort, + Eligibility, Description, Theme, + Documentation, State Presubmission, + Semifinalist Activity, Regional Notes. + + + @if (!string.IsNullOrEmpty(_fileName)) + { + @_fileName + } + + + Parse + + + Clear + + + + + + + + + Parsed Results + @if (_isParsing) + { + + Parsing... + } + else if (!string.IsNullOrEmpty(_parseError)) + { + @_parseError + } + else if (_events is null) + { + Upload and parse a CSV to see results here + } + else + { + + + @_events.Length event(s) parsed. + @_newEventCount new, @_existingEventCount already in the database. + + + Save to Database + + + } + + + + +@code { + private byte[]? _fileBytes; + private string? _fileName; + private EventDefinition[]? _events; + private int _newEventCount; + private int _existingEventCount; + private string? _parseError; + private bool _isParsing; + private bool _isSaving; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + private async Task HandleFileChanged(InputFileChangeEventArgs args) + { + if (_isDisposed) + return; + + try + { + await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024); + await using var memory = new MemoryStream(); + await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None); + _fileBytes = memory.ToArray(); + _fileName = args.File.Name; + ResetParse(); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error reading event catalog CSV"); + if (!_isDisposed) + Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error); + } + } + + private async Task HandleParse() + { + if (_fileBytes is null) + { + Snackbar.Add("Please choose a CSV file first", Severity.Warning); + return; + } + + _isParsing = true; + _parseError = null; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + using var reader = new StreamReader(new MemoryStream(_fileBytes)); + _events = new EventDefinitionParser(reader).Parse(); + + var existingNames = await Context.Events + .AsNoTracking() + .Select(e => e.Name) + .ToListAsync(token); + var existingSet = existingNames.ToHashSet(); + + _existingEventCount = _events.Count(e => existingSet.Contains(e.Name)); + _newEventCount = _events.Length - _existingEventCount; + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error parsing event catalog CSV"); + _events = null; + _parseError = $"Error parsing CSV: {ex.Message}"; + if (!_isDisposed) + Snackbar.Add(_parseError, Severity.Error); + } + finally + { + _isParsing = false; + } + } + + private async Task HandleSave() + { + if (_events is null) + { + Snackbar.Add("Parse a CSV first", Severity.Warning); + return; + } + + _isSaving = true; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + var added = 0; + foreach (var evt in _events) + { + token.ThrowIfCancellationRequested(); + var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token); + if (exists != null) + continue; + await Context.Events.AddAsync(evt, token); + added++; + } + + await Context.SaveChangesAsync(token); + if (_isDisposed) + return; + + Snackbar.Add($"Added {added} event(s).", Severity.Success); + NavigationManager.NavigateTo("/events"); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error saving imported events"); + if (!_isDisposed) + Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + } + } + + private void HandleClear() + { + _fileBytes = null; + _fileName = null; + ResetParse(); + } + + private void ResetParse() + { + _events = null; + _newEventCount = 0; + _existingEventCount = 0; + _parseError = null; + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Features/Events/Index.razor b/WebApp/Components/Features/Events/Index.razor index 186f918..76f5407 100644 --- a/WebApp/Components/Features/Events/Index.razor +++ b/WebApp/Components/Features/Events/Index.razor @@ -4,6 +4,7 @@ @using Microsoft.EntityFrameworkCore @using WebApp.Models @using WebApp.Components.Shared.Components +@using WebApp.Authentication @inject AppDbContext Context @inject IDialogService DialogService @inject ISnackbar Snackbar @@ -13,6 +14,11 @@ Create New + + + Import + + Printable Descriptions diff --git a/WebApp/Components/Features/Students/Components/StudentNotePanel.razor b/WebApp/Components/Features/Students/Components/StudentNotePanel.razor new file mode 100644 index 0000000..e2f7d40 --- /dev/null +++ b/WebApp/Components/Features/Students/Components/StudentNotePanel.razor @@ -0,0 +1,104 @@ +@using Core.Services +@using PSC.Blazor.Components.MarkdownEditor +@inject INotesService NotesService +@inject INoteNamingService NoteNamingService +@inject MarkdownTablePasteService MarkdownTablePasteService +@inject ISnackbar Snackbar +Notes +@if (_isLoading) +{ + +} +else if (ReadOnly) +{ + @if (string.IsNullOrWhiteSpace(_content)) + { + No notes yet. + } + else + { + @((MarkupString)MarkdownHelper.ToHtml(_content)) + } +} +else +{ + Markdown is supported. Imported fields appear in a table and can be edited. + +} + +@code { + [Parameter] + public int StudentId { get; set; } + + [Parameter] + public bool ReadOnly { get; set; } + + private string _content = string.Empty; + private int? _noteId; + private int _loadedStudentId; + private bool _isLoading = true; + private bool _pasteInitialized; + + protected override async Task OnParametersSetAsync() + { + if (StudentId <= 0 || _loadedStudentId == StudentId) + return; + + _isLoading = true; + try + { + var note = await NotesService.GetStudentNoteAsync(StudentId); + _noteId = note?.Id; + _content = note?.Content ?? string.Empty; + _loadedStudentId = StudentId; + } + finally + { + _isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (ReadOnly || _pasteInitialized) + return; + + await Task.Delay(150); + await MarkdownTablePasteService.InitializeAsync(); + _pasteInitialized = true; + } + + public async Task SaveAsync() + { + if (StudentId <= 0) + return; + + if (_noteId is null) + { + if (string.IsNullOrWhiteSpace(_content)) + return; + + var created = await NotesService.CreateNoteAsync(new Note + { + Title = NoteNamingService.GetStudentNoteTitle(StudentId), + Content = _content + }); + _noteId = created.Id; + return; + } + + var existing = await NotesService.GetNoteAsync(_noteId.Value); + if (existing is null) + return; + + if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal)) + return; + + existing.Content = _content; + await NotesService.UpdateNoteAsync(existing); + } +} diff --git a/WebApp/Components/Features/Students/Details.razor b/WebApp/Components/Features/Students/Details.razor index d0ea9a2..f7bd62c 100644 --- a/WebApp/Components/Features/Students/Details.razor +++ b/WebApp/Components/Features/Students/Details.razor @@ -82,6 +82,10 @@ + + + + @code { private Student? student; diff --git a/WebApp/Components/Features/Students/Edit.razor b/WebApp/Components/Features/Students/Edit.razor index 0540e2f..8478505 100644 --- a/WebApp/Components/Features/Students/Edit.razor +++ b/WebApp/Components/Features/Students/Edit.razor @@ -52,6 +52,11 @@ + + + + + @@ -73,6 +78,7 @@ private FormChangeTracker? _formChangeTracker; private EditContext? _editContext; private List _validationErrors = new(); + private StudentNotePanel? _notePanel; protected override async Task OnInitializedAsync() { @@ -120,6 +126,8 @@ try { await Context.SaveChangesAsync(); + if (_notePanel is not null) + await _notePanel.SaveAsync(); Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success); _formChangeTracker?.AllowNavigation(); NavigationManager.NavigateTo(ReturnUrl ?? "/students"); diff --git a/WebApp/Components/Features/Students/Index.razor b/WebApp/Components/Features/Students/Index.razor index d7fd32e..0bf59d5 100644 --- a/WebApp/Components/Features/Students/Index.razor +++ b/WebApp/Components/Features/Students/Index.razor @@ -2,17 +2,40 @@ @attribute [Authorize] @implements IAsyncDisposable @using Microsoft.EntityFrameworkCore +@using WebApp.Authentication @using WebApp.Models @using WebApp.Components.Shared.Components @inject AppDbContext Context @inject IDialogService DialogService @inject ISnackbar Snackbar +@inject INotesService NotesService +@inject IConfiguration Configuration +@inject IJSRuntime JSRuntime +@using Core.Notes +@using Core.Parsers +@using WebApp.Services Create New + + + + Import + + + + Download CSV template + + + + Event Rankings @@ -24,6 +47,7 @@ @((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade)) (@context.Item.TsaYear) + @foreach (var field in _noteFieldColumns) + { + var fieldName = field; + + + @GetNoteField(context.Item.Id, fieldName) + + + } @@ -77,12 +110,20 @@ private bool _isLoading = true; private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed = false; + private List _noteFieldColumns = []; + private Dictionary _noteContentByStudentId = []; + private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns); protected override void OnInitialized() { _cancellationTokenSource = new CancellationTokenSource(); } + protected override void OnParametersSet() + { + _noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration); + } + private async Task> ServerReload(GridState state) { if (_isDisposed) @@ -104,6 +145,9 @@ var totalItems = await query.CountAsync(cancellationToken); var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken); + var notes = await NotesService.GetStudentNotesAsync(pagedData.Select(s => s.Id)); + _noteContentByStudentId = notes.ToDictionary(k => k.Key, v => v.Value.Content); + return new GridData { TotalItems = totalItems, @@ -160,6 +204,7 @@ return; } + await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken); Context.Students.Remove(studentToDelete); await Context.SaveChangesAsync(cancellationToken); @@ -203,4 +248,42 @@ } await ValueTask.CompletedTask; } + + private async Task DownloadStudentImportTemplate() + { + if (_isDisposed) + return; + + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + var fromNotes = await NotesService.GetImportedFieldNamesAsync(cancellationToken); + var leftover = _noteFieldColumns + .Concat(fromNotes) + .Distinct(StringComparer.OrdinalIgnoreCase); + var csv = StudentImportCsvTemplate.Build(leftover); + byte[] bytes = [..System.Text.Encoding.UTF8.GetPreamble(), ..System.Text.Encoding.UTF8.GetBytes(csv)]; + var base64 = Convert.ToBase64String(bytes); + await JSRuntime.InvokeVoidAsync("tsaDownload.fromBase64", "student-import-template.csv", "text/csv;charset=utf-8", base64); + } + catch (JSDisconnectedException) + { + } + catch (TaskCanceledException) + { + } + catch (Exception ex) + { + if (!_isDisposed) + Snackbar.Add($"Could not download template: {ex.Message}", Severity.Error); + } + } + + private string GetNoteField(int studentId, string fieldName) + { + if (!_noteContentByStudentId.TryGetValue(studentId, out var content)) + return string.Empty; + + return ImportedFieldsTable.GetFieldValue(content, fieldName) ?? string.Empty; + } } diff --git a/WebApp/Components/Features/Students/StudentImport.razor b/WebApp/Components/Features/Students/StudentImport.razor new file mode 100644 index 0000000..d9e4182 --- /dev/null +++ b/WebApp/Components/Features/Students/StudentImport.razor @@ -0,0 +1,301 @@ +@page "/students/import" +@page "/import" +@attribute [Authorize(Roles = AuthRoles.Administrator)] +@implements IAsyncDisposable +@using Core.Parsers +@using Core.Services +@using Microsoft.EntityFrameworkCore +@using WebApp.Authentication +@inject AppDbContext Context +@inject IStudentNotesImportService NotesImportService +@inject IStudentNotesImportSaveService NotesSaveService +@inject INotesService NotesService +@inject NavigationManager NavigationManager +@inject ISnackbar Snackbar +@inject ILogger Logger +@rendermode InteractiveServer + + + + + + + Upload CSV + + + Required columns: Student Name, Grade, TSA year. + Optional IDs are saved on new students. Every other column is merged into that student's notes. + + + @if (!string.IsNullOrEmpty(_fileName)) + { + @_fileName + } + + + Parse + + + Clear + + + + + + + + + Parsed Results + @if (_isParsing) + { + + Parsing... + } + else if (!string.IsNullOrEmpty(_parseError)) + { + @_parseError + } + else if (_students is null) + { + Upload and parse a CSV to see results here + } + else + { + + + @_students.Length student(s) parsed. + @_newStudentCount new, @_existingStudentCount already in the database. + + @if (_leftoverFieldNames.Count > 0) + { + Leftover note fields: @string.Join(", ", _leftoverFieldNames) + } + else + { + No leftover note fields in this file. + } + + + Save to Database + + + } + + + + +@code { + private byte[]? _fileBytes; + private string? _fileName; + private Student[]? _students; + private List _leftoverFieldNames = []; + private int _newStudentCount; + private int _existingStudentCount; + private string? _parseError; + private bool _isParsing; + private bool _isSaving; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + private async Task HandleFileChanged(InputFileChangeEventArgs args) + { + if (_isDisposed) + return; + + try + { + await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024); + await using var memory = new MemoryStream(); + await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None); + _fileBytes = memory.ToArray(); + _fileName = args.File.Name; + ResetParse(); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error reading student CSV"); + if (!_isDisposed) + Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error); + } + } + + private async Task HandleParse() + { + if (_fileBytes is null) + { + Snackbar.Add("Please choose a CSV file first", Severity.Warning); + return; + } + + _isParsing = true; + _parseError = null; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + using var reader = new StreamReader(new MemoryStream(_fileBytes)); + _students = new StudentParser(reader).Parse(); + _leftoverFieldNames = PeekLeftoverFieldNames(_fileBytes); + + var existingNames = await Context.Students + .AsNoTracking() + .Select(s => new { s.FirstName, s.LastName }) + .ToListAsync(token); + var existingSet = existingNames + .Select(s => (s.FirstName, s.LastName)) + .ToHashSet(); + + _existingStudentCount = _students.Count(s => existingSet.Contains((s.FirstName, s.LastName))); + _newStudentCount = _students.Length - _existingStudentCount; + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error parsing student CSV"); + _students = null; + _leftoverFieldNames = []; + _parseError = $"Error parsing CSV: {ex.Message}"; + if (!_isDisposed) + Snackbar.Add(_parseError, Severity.Error); + } + finally + { + _isParsing = false; + } + } + + private async Task HandleSave() + { + if (_students is null || _fileBytes is null) + { + Snackbar.Add("Parse a CSV first", Severity.Warning); + return; + } + + _isSaving = true; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + var added = 0; + foreach (var student in _students) + { + token.ThrowIfCancellationRequested(); + var exists = await Context.Students + .FirstOrDefaultAsync(e => e.FirstName == student.FirstName && e.LastName == student.LastName, token); + if (exists != null) + continue; + await Context.Students.AddAsync(student, token); + added++; + } + + await Context.SaveChangesAsync(token); + + var notesCreated = 0; + var notesUpdated = 0; + if (_leftoverFieldNames.Count > 0) + { + var students = await Context.Students + .AsNoTracking() + .OrderBy(s => s.LastName) + .ThenBy(s => s.FirstName) + .ToListAsync(token); + var notes = await NotesService.GetStudentNotesAsync(students.Select(s => s.Id)); + var existing = notes.ToDictionary(k => k.Key, v => v.Value.Content); + + await using var csvStream = new MemoryStream(_fileBytes, writable: false); + var parseResult = NotesImportService.Parse(csvStream, students, existing); + if (parseResult.IsSuccess && parseResult.StudentsWithChanges > 0) + { + var saveResult = await NotesSaveService.SaveAsync(parseResult, token); + notesCreated = saveResult.NotesCreated; + notesUpdated = saveResult.NotesUpdated; + } + } + + if (_isDisposed) + return; + + Snackbar.Add( + $"Added {added} student(s). Notes created {notesCreated}, updated {notesUpdated}.", + Severity.Success); + NavigationManager.NavigateTo("/students"); + } + catch (TaskCanceledException) + { + } + catch (JSDisconnectedException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error saving imported students"); + if (!_isDisposed) + Snackbar.Add($"Error saving students: {ex.Message}", Severity.Error); + } + finally + { + _isSaving = false; + } + } + + private void HandleClear() + { + _fileBytes = null; + _fileName = null; + ResetParse(); + } + + private void ResetParse() + { + _students = null; + _leftoverFieldNames = []; + _newStudentCount = 0; + _existingStudentCount = 0; + _parseError = null; + } + + private static List PeekLeftoverFieldNames(byte[] csvBytes) + { + using var reader = new StreamReader(new MemoryStream(csvBytes)); + using var parser = new StudentNotesFieldParser(reader); + return parser.PeekLeftoverFieldNames(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Pages/ChapterSettings.razor b/WebApp/Components/Pages/ChapterSettings.razor index 4498f50..23707f5 100644 --- a/WebApp/Components/Pages/ChapterSettings.razor +++ b/WebApp/Components/Pages/ChapterSettings.razor @@ -1,5 +1,6 @@ @page "/settings/chapter" @attribute [Authorize(Roles = AuthRoles.Administrator)] +@implements IAsyncDisposable @using WebApp.Authentication @using WebApp.Models @using WebApp.Components.Shared.Components @@ -7,12 +8,13 @@ @using Core.Models @inject IConfiguration Configuration @inject IChapterSettingsWriter ChapterSettingsWriter +@inject INotesService NotesService @rendermode InteractiveServer + Description="Configure chapter information. Student index columns apply the next time you open Students. Printouts that cache chapter name or year may still need a restart." /> @@ -90,6 +92,41 @@ + + Student Index Columns + + Field names from each student's imported notes table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line). + + @if (_availableFields.Count > 0) + { + Fields in student notes + + @foreach (var field in _availableFields) + { + var selected = IsFieldSelected(field); + + @field + + } + + } + else + { + + No imported fields found in student notes yet. + + } + + + @@ -125,14 +162,52 @@ @code { private Models.ChapterSettings? _settings; + private string _noteFieldsText = string.Empty; + private IReadOnlyList _availableFields = []; private bool _isSaving; private string? _statusMessage; private Severity _statusSeverity = Severity.Success; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed; protected override void OnInitialized() { - _settings = Configuration.GetSection("ChapterSettings").Get() - ?? new Models.ChapterSettings(); + _cancellationTokenSource = new CancellationTokenSource(); + _settings = Models.ChapterSettings.FromConfiguration(Configuration); + _noteFieldsText = string.Join(Environment.NewLine, _settings.StudentIndexNoteFields); + } + + protected override async Task OnInitializedAsync() + { + try + { + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + _availableFields = await NotesService.GetImportedFieldNamesAsync(cancellationToken); + } + catch (OperationCanceledException) + { + } + } + + private IReadOnlyList SelectedFields() => + _noteFieldsText + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + private bool IsFieldSelected(string field) => + SelectedFields().Any(selected => selected.Equals(field, StringComparison.OrdinalIgnoreCase)); + + private void ToggleField(string field) + { + var selected = SelectedFields().ToList(); + var index = selected.FindIndex(name => name.Equals(field, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + selected.RemoveAt(index); + else + selected.Add(field); + + _noteFieldsText = string.Join(Environment.NewLine, selected); } private async Task SaveSettings() @@ -144,18 +219,41 @@ try { - await ChapterSettingsWriter.WriteAsync(_settings); - _statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; + _settings.StudentIndexNoteFields = [.. SelectedFields()]; + var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None; + await ChapterSettingsWriter.WriteAsync(_settings, cancellationToken); + if (_isDisposed) + return; + _statusMessage = "Settings saved. Open Students again to see the updated index columns."; _statusSeverity = Severity.Success; } + catch (OperationCanceledException) + { + } catch (Exception ex) { + if (_isDisposed) + return; _statusMessage = $"Error saving settings: {ex.Message}"; _statusSeverity = Severity.Error; } finally { - _isSaving = false; + if (!_isDisposed) + _isSaving = false; } } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + + await ValueTask.CompletedTask; + } } diff --git a/WebApp/Components/Pages/Import.razor b/WebApp/Components/Pages/Import.razor deleted file mode 100644 index c6974d2..0000000 --- a/WebApp/Components/Pages/Import.razor +++ /dev/null @@ -1,115 +0,0 @@ -@page "/import" -@attribute [Authorize(Roles = AuthRoles.Administrator)] -@using Core.Parsers -@using Microsoft.EntityFrameworkCore -@using WebApp.Authentication -@inject AppDbContext Context - -@rendermode InteractiveServer - -Import Data - -

Import Data

- -

Events

- - @_events?.Length Events - -
- -

Students

- -@_students?.Length Students - - -@code { - private EventDefinition[]? _events; - private Student[]? _students; - - async Task UploadEvents(InputFileChangeEventArgs arg) - { - await GetStreamReaderFromInputFile(arg, reader => - { - var eventDefinitionParser = new EventDefinitionParser(reader); - _events = eventDefinitionParser.Parse(); - }); - } - - async Task SaveEvents() - { - if (_events == null) - return; - - foreach (var evt in _events) - { - // check if it already exists - var exists - = await Context.Events - .FirstOrDefaultAsync(e => e.Name == evt.Name); - if (exists != null) - continue; - await Context.Events.AddAsync(evt); - } - - await Context.SaveChangesAsync(); - } - - async Task UploadStudents(InputFileChangeEventArgs arg) - { - await GetStreamReaderFromInputFile(arg, reader => - { - var studentParser = new StudentParser(reader); - _students = studentParser.Parse(); - }); - } - - async Task SaveStudents() - { - if (_students == null) - return; - - try - { - foreach (var student in _students) - { - // check if it already exists - var exists - = await Context.Students - .FirstOrDefaultAsync(e - => e.FirstName == student.FirstName - && e.LastName == student.LastName); - if (exists != null) - continue; - await Context.Students.AddAsync(student); - } - await Context.SaveChangesAsync(); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - - static async Task GetStreamReaderFromInputFile(InputFileChangeEventArgs arg, Action f) - { - StreamReader? streamReader = null; - try - { - var browserFile = arg.File; - - await using var fs = browserFile.OpenReadStream(); - await using var ms = new MemoryStream(); - - await fs.CopyToAsync(ms); - ms.Seek(0,0); - streamReader = new StreamReader(ms); - f(streamReader); - } - catch - { - streamReader?.Dispose(); - throw; - } - } -} diff --git a/WebApp/Models/ChapterSettings.cs b/WebApp/Models/ChapterSettings.cs index f376e54..9fc7013 100644 --- a/WebApp/Models/ChapterSettings.cs +++ b/WebApp/Models/ChapterSettings.cs @@ -1,4 +1,6 @@ using Core.Models; +using Core.Notes; +using Microsoft.Extensions.Configuration; namespace WebApp.Models; @@ -51,4 +53,26 @@ public class ChapterSettings /// School level for the chapter (null = import both MS and HS events) /// public SchoolLevel? SchoolLevel { get; set; } + + /// + /// Field names from student note ## Imported fields tables to show as Students index columns. + /// + public List StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns]; + + public static ChapterSettings FromConfiguration(IConfiguration configuration) + { + var settings = configuration.GetSection("ChapterSettings").Get() + ?? new ChapterSettings(); + settings.StudentIndexNoteFields = ReadIndexNoteFields(configuration); + return settings; + } + + public static List ReadIndexNoteFields(IConfiguration configuration) + { + var fields = configuration.GetSection("ChapterSettings:StudentIndexNoteFields").Get>(); + if (fields is null) + return [.. StudentNoteFieldDefaults.IndexColumns]; + + return [.. fields.Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim())]; + } } diff --git a/WebApp/Program.cs b/WebApp/Program.cs index 52f3a55..23c27fe 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using Serilog; using System.Text.Json; +using System.Text.Json.Nodes; +using Core.Notes; using VisNetwork.Blazor; using WebApp; using WebApp.Authentication; @@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath)) var baseConfig = File.ReadAllText(baseAppSettingsPath); var baseDoc = JsonDocument.Parse(baseConfig); - var templateSettings = new Dictionary(); + JsonObject templateSettings = []; if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings)) { - templateSettings["ChapterSettings"] = JsonSerializer.Deserialize(chapterSettings.GetRawText()); + var chapterObj = JsonNode.Parse(chapterSettings.GetRawText()) as JsonObject ?? []; + if (chapterObj["StudentIndexNoteFields"] is null) + chapterObj["StudentIndexNoteFields"] = JsonSerializer.SerializeToNode(StudentNoteFieldDefaults.IndexColumns); + templateSettings["ChapterSettings"] = chapterObj; } if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings)) { - templateSettings["ValidationSettings"] = JsonSerializer.Deserialize(validationSettings.GetRawText()); + templateSettings["ValidationSettings"] = JsonNode.Parse(validationSettings.GetRawText()); } - if (templateSettings.Any()) + if (templateSettings.Count > 0) { - var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions + var json = templateSettings.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); @@ -206,6 +211,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure( builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); diff --git a/WebApp/Services/ChapterSettingsWriter.cs b/WebApp/Services/ChapterSettingsWriter.cs index 1781e5c..506c28e 100644 --- a/WebApp/Services/ChapterSettingsWriter.cs +++ b/WebApp/Services/ChapterSettingsWriter.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using WebApp.Models; namespace WebApp.Services; @@ -8,6 +9,11 @@ namespace WebApp.Services; /// public class ChapterSettingsWriter : IChapterSettingsWriter { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true + }; + private readonly IWebHostEnvironment _environment; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter var appSettingsPath = GetAppSettingsPath(); var dataDir = Path.GetDirectoryName(appSettingsPath); if (dataDir != null && !Directory.Exists(dataDir)) - { Directory.CreateDirectory(dataDir); - } - Dictionary root; + JsonObject root; if (File.Exists(appSettingsPath)) { var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken); - root = JsonSerializer.Deserialize>(existingJson) - ?? []; + root = JsonNode.Parse(existingJson) as JsonObject ?? []; } else { root = []; } - root["ChapterSettings"] = settings; + var incoming = JsonSerializer.SerializeToNode(settings, JsonOptions) as JsonObject ?? []; + if (root["ChapterSettings"] is JsonObject existing) + { + foreach (var property in incoming) + existing[property.Key] = property.Value?.DeepClone(); + } + else + { + root["ChapterSettings"] = incoming; + } - var options = new JsonSerializerOptions { WriteIndented = true }; - var json = JsonSerializer.Serialize(root, options); - await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken); + await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken); + + if (_configuration is IConfigurationRoot configurationRoot) + configurationRoot.Reload(); _logger.LogInformation("Chapter settings written to {Path}", appSettingsPath); } public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default) { - var settings = _configuration.GetSection("ChapterSettings").Get() - ?? new ChapterSettings(); + var settings = ChapterSettings.FromConfiguration(_configuration); settings.CompetitionYear = competitionYear; await WriteAsync(settings, cancellationToken); } diff --git a/WebApp/Services/INotesService.cs b/WebApp/Services/INotesService.cs index 1020fca..95cbc7e 100644 --- a/WebApp/Services/INotesService.cs +++ b/WebApp/Services/INotesService.cs @@ -22,6 +22,26 @@ public interface INotesService /// The note with title "#{pageIdentifier}" or null if not found Task GetPageNoteAsync(string pageIdentifier); + /// + /// Gets the system note for a student, or null if none exists. + /// + Task GetStudentNoteAsync(int studentId); + + /// + /// Gets system notes for the given student ids, keyed by student id. + /// + Task> GetStudentNotesAsync(IEnumerable studentIds); + + /// + /// Soft-deletes system notes for the given student ids. + /// + Task SoftDeleteStudentNotesAsync(IEnumerable studentIds, CancellationToken cancellationToken = default); + + /// + /// Distinct Field names from student note ## Imported fields tables. + /// + Task> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default); + /// /// Gets all history entries for a note. /// diff --git a/WebApp/Services/IStudentNotesImportSaveService.cs b/WebApp/Services/IStudentNotesImportSaveService.cs new file mode 100644 index 0000000..f1e17a1 --- /dev/null +++ b/WebApp/Services/IStudentNotesImportSaveService.cs @@ -0,0 +1,22 @@ +using Core.Models; + +namespace WebApp.Services; + +/// +/// Persists merged student field notes. +/// +public interface IStudentNotesImportSaveService +{ + Task SaveAsync( + StudentNotesImportResult parseResult, + CancellationToken cancellationToken = default); +} + +public class StudentNotesImportSaveResult +{ + public int NotesCreated { get; set; } + + public int NotesUpdated { get; set; } + + public int StudentsUnchanged { get; set; } +} diff --git a/WebApp/Services/NotesService.cs b/WebApp/Services/NotesService.cs index 6c5d844..693a46e 100644 --- a/WebApp/Services/NotesService.cs +++ b/WebApp/Services/NotesService.cs @@ -1,4 +1,5 @@ using Core.Entities; +using Core.Notes; using Core.Services; using Data; using Microsoft.EntityFrameworkCore; @@ -46,8 +47,9 @@ public class NotesService : INotesService } return await query - .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) - .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated + .Where(n => n.Title == null || !n.Title.StartsWith("#Student:")) + .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) + .ThenByDescending(n => n.UpdatedAt) .ToListAsync(); } @@ -67,6 +69,84 @@ public class NotesService : INotesService .FirstOrDefaultAsync(); } + public async Task GetStudentNoteAsync(int studentId) + { + var title = _noteNamingService.GetStudentNoteTitle(studentId); + return await _context.Notes + .AsNoTracking() + .Where(n => n.Title == title && !n.IsDeleted) + .FirstOrDefaultAsync(); + } + + public async Task> GetStudentNotesAsync(IEnumerable studentIds) + { + var ids = studentIds.Distinct().ToList(); + if (ids.Count == 0) + return new Dictionary(); + + var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList(); + var notes = await _context.Notes + .AsNoTracking() + .Where(n => titles.Contains(n.Title) && !n.IsDeleted) + .ToListAsync(); + + Dictionary byStudentId = []; + foreach (var note in notes) + { + if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId)) + byStudentId[studentId] = note; + } + + return byStudentId; + } + + public async Task SoftDeleteStudentNotesAsync(IEnumerable studentIds, CancellationToken cancellationToken = default) + { + var ids = studentIds.Distinct().ToList(); + if (ids.Count == 0) + return; + + var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList(); + var notes = await _context.Notes + .Where(n => titles.Contains(n.Title) && !n.IsDeleted) + .ToListAsync(cancellationToken); + + if (notes.Count == 0) + return; + + var userEmail = GetCurrentUserEmail(); + var now = DateTime.UtcNow; + foreach (var note in notes) + { + _context.NoteHistories.Add(new NoteHistory + { + NoteId = note.Id, + Title = note.Title, + Content = note.Content, + ModifiedBy = userEmail, + ModifiedAt = now, + ChangeType = "Soft Deleted" + }); + note.IsDeleted = true; + note.UpdatedAt = now; + note.LastModifiedBy = userEmail; + } + + await _context.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Soft-deleted {Count} student note(s)", notes.Count); + } + + public async Task> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default) + { + var contents = await _context.Notes + .AsNoTracking() + .Where(n => !n.IsDeleted && n.Title != null && n.Title.StartsWith("#Student:")) + .Select(n => n.Content) + .ToListAsync(cancellationToken); + + return ImportedFieldsTable.DistinctFieldNames(contents); + } + public async Task> GetNoteHistoryAsync(int noteId) { return await _context.NoteHistories @@ -253,9 +333,9 @@ public class NotesService : INotesService { return await _context.Notes .AsNoTracking() - .Where(n => n.IsDeleted) - .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) - .ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated + .Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:"))) + .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) + .ThenByDescending(n => n.UpdatedAt) .ToListAsync(); } diff --git a/WebApp/Services/StudentNotesImportSaveService.cs b/WebApp/Services/StudentNotesImportSaveService.cs new file mode 100644 index 0000000..de9d4b1 --- /dev/null +++ b/WebApp/Services/StudentNotesImportSaveService.cs @@ -0,0 +1,64 @@ +using Core.Models; +using Core.Services; + +namespace WebApp.Services; + +/// +/// Creates or updates #Student:{id} notes when imported fields actually change. +/// +public class StudentNotesImportSaveService : IStudentNotesImportSaveService +{ + private readonly INotesService _notesService; + private readonly INoteNamingService _noteNamingService; + private readonly ILogger _logger; + + public StudentNotesImportSaveService( + INotesService notesService, + INoteNamingService noteNamingService, + ILogger logger) + { + _notesService = notesService; + _noteNamingService = noteNamingService; + _logger = logger; + } + + /// + public async Task SaveAsync( + StudentNotesImportResult parseResult, + CancellationToken cancellationToken = default) + { + var result = new StudentNotesImportSaveResult(); + var existingNotes = await _notesService.GetStudentNotesAsync( + parseResult.Matches.Select(m => m.Student.Id)); + var actions = StudentNotesImportPlan.Create(parseResult, existingNotes.Keys.ToHashSet()); + result.StudentsUnchanged = parseResult.Matches.Count - actions.Count; + + foreach (var action in actions) + { + cancellationToken.ThrowIfCancellationRequested(); + if (action.Kind == StudentNotePersistKind.Update + && existingNotes.TryGetValue(action.StudentId, out var existing)) + { + existing.Content = action.Markdown; + await _notesService.UpdateNoteAsync(existing); + result.NotesUpdated++; + continue; + } + + await _notesService.CreateNoteAsync(new Core.Entities.Note + { + Title = _noteNamingService.GetStudentNoteTitle(action.StudentId), + Content = action.Markdown + }); + result.NotesCreated++; + } + + _logger.LogInformation( + "Student notes import saved. Created={Created}, Updated={Updated}, Unchanged={Unchanged}", + result.NotesCreated, + result.NotesUpdated, + result.StudentsUnchanged); + + return result; + } +} diff --git a/WebApp/Services/YearRolloverService.cs b/WebApp/Services/YearRolloverService.cs index 9f3a137..6f84f35 100644 --- a/WebApp/Services/YearRolloverService.cs +++ b/WebApp/Services/YearRolloverService.cs @@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService private readonly AppDbContext _context; private readonly IDatabaseBackupService _backupService; private readonly IChapterSettingsWriter _chapterSettingsWriter; + private readonly INotesService _notesService; private readonly ILogger _logger; public YearRolloverService( AppDbContext context, IDatabaseBackupService backupService, IChapterSettingsWriter chapterSettingsWriter, + INotesService notesService, ILogger logger) { _context = context; _backupService = backupService; _chapterSettingsWriter = chapterSettingsWriter; + _notesService = notesService; _logger = logger; } @@ -107,6 +110,8 @@ public class YearRolloverService : IYearRolloverService $"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again."); } + await _notesService.SoftDeleteStudentNotesAsync(removalIds, cancellationToken); + _context.Students.RemoveRange(toRemove); var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id); diff --git a/WebApp/wwwroot/js/downloadFile.js b/WebApp/wwwroot/js/downloadFile.js new file mode 100644 index 0000000..b63d0a0 --- /dev/null +++ b/WebApp/wwwroot/js/downloadFile.js @@ -0,0 +1,21 @@ +window.tsaDownload = { + /** + * Triggers a browser file download from a base64 payload. + */ + fromBase64: function (filename, contentType, base64) { + var binary = atob(base64); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + + var blob = new Blob([bytes], { type: contentType }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } +}; diff --git a/docs/instructions/event-catalog-import.md b/docs/instructions/event-catalog-import.md new file mode 100644 index 0000000..9a4226d --- /dev/null +++ b/docs/instructions/event-catalog-import.md @@ -0,0 +1,23 @@ +# Import Event Catalog + +**Created:** 2026-08-29 +**Last updated:** 2026-08-29 +**Description:** How `/events/import` adds event definitions from CSV. + +## Where this is + +This is the **event catalog** (names, team size, format). It is not student event rankings (`/students/event-ranking/import`) and not the calendar schedule (`/calendar/event-occurrences/import`). + +## Steps + +1. Sign in as an Administrator. +2. Open **Events** and click **Import**, or go to `/events/import`. +3. Upload a CSV, **Parse**, review new vs existing counts, then **Save to Database**. + +Existing event names are skipped. Re-importing the same catalog is safe. + +## Required columns + +`Event`, `Team Size`, `State Count` + +Optional columns the parser already reads include `Short Name`, `EventFormat`, `Level of Effort`, `Eligibility`, `Description`, `Theme`, `Documentation`, `State Presubmission`, `Semifinalist Activity`, and `Regional Notes`. diff --git a/docs/instructions/student-notes-import.md b/docs/instructions/student-notes-import.md new file mode 100644 index 0000000..caf909e --- /dev/null +++ b/docs/instructions/student-notes-import.md @@ -0,0 +1,50 @@ +# Import Students and Note Fields + +**Created:** 2026-08-29 +**Last updated:** 2026-08-29 + +**Description:** How `/students/import` creates students and merges leftover CSV columns into each student's markdown notes. + +## Where notes live + +Each student has a system note titled `#Student:{id}`. It is edited on the student edit page and shown on student details. Those notes are hidden from the main Notes list. + +Imported leftover values go in a table under `## Imported fields`. Other markdown above or below that heading is left alone. + +## CSV format + +``` +Student Name,Grade,TSA year,State ID,Regional ID,National ID,Interview Time,Application,Club Permission Slip,Teacher Rec 1,Teacher Rec 2,Teacher Rec 3 +"Last, First",6,1st,,,,3:20-3:35,x,x,Fuqua,Young, +``` + +- Roster (required): `Student Name` (`Last, First` or `First Last`), `Grade`, `TSA year`. +- Roster (optional): `State ID`, `Regional ID`, `National ID`. +- Every other column becomes a Field/Value row. Later files can add columns without a code change. +- A cell that is only `x` / `X` is stored as `Yes`. Blank stays blank. +- Rank columns `1`–`10`, `Officer`, and `TOTAL # OF EVENTS` are ignored (use `/students/event-ranking/import` for rankings). + +A notes-only file without `Grade` will not import. Put roster and leftover columns in the same CSV. + +## Merge rules + +- New students are inserted. Existing first+last name matches are skipped for the roster (add-only). +- After students are saved, leftover columns are fuzzy-matched by name and merged into notes. Existing students still receive note updates. +- Incoming values win when a field already exists, including a blank cell that clears a previous value. +- New fields are appended. Fields not in this CSV stay. Blank cells are stored and re-imported as a no-op. +- Duplicate rows for the same student collapse into one note match; the last row wins for overlapping field names. +- Importing the same file twice adds no students and writes no note history. + +## Steps + +1. Sign in as an Administrator. +2. Open **Students** and click **Import**, or go to `/students/import`. + The arrow next to **Import** downloads a CSV template (roster columns, Students index columns, and any other imported fields already in notes). + `/import` still opens this same student page. +3. Upload the student CSV. +4. Review new vs existing counts and leftover field names. +5. **Save to Database**. + +## Index columns + +Chapter Settings → **Student Index Columns** lists which imported field names appear as extra columns on the Students index. The page also shows field names already present in student notes; click a chip to add or remove it from the list. Open Students again after saving; a restart is not required. diff --git a/docs/instructions/year-rollover.md b/docs/instructions/year-rollover.md index 9b43c89..2a3c04e 100644 --- a/docs/instructions/year-rollover.md +++ b/docs/instructions/year-rollover.md @@ -1,7 +1,8 @@ # Year Rollover Runbook **Created:** 2026-08-14 -**Last updated:** 2026-08-28 +**Last updated:** 2026-08-29 + **Description:** How to roll the chapter into a new competition year using the locked New Year wizard. ## Prerequisites @@ -36,9 +37,10 @@ - Type the target competition year exactly to enable **Apply rollover**. - Confirm the destructive dialog. The wizard creates `Data/backups/pre-rollover-yyyyMMdd-HHmmss.db` first; that file is the only undo. 9. **Restart the application** so the home page and printouts show the new competition year. -10. **Add new students** via `/students/create` or `/import`. - - `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners. -11. **Import the new state schedule** from the calendar import page. +10. **Add new students** via `/students/create` or `/students/import`. + - Student import is add-only for roster rows and skips existing first+last name matches, so re-importing a full roster is safe for returners. + - Leftover CSV columns (interview time, application, and so on) are merged into each student's notes. Rank columns stay on `/students/event-ranking/import`. See `docs/instructions/student-notes-import.md`. +11. **Import the new state schedule** from the calendar import page. If the chapter event file changed, refresh the catalog at `/events/import` (add-only by event name). 12. On **Meeting Schedule**, click **Reset** once. That page keeps team/student ids in browser localStorage; after a rollover those ids are stale. 13. Collect new event rankings (CSV import at `/students/event-ranking/import`, or the ranking editor) and run team assignment as usual. @@ -48,7 +50,7 @@ |---------|------| | Non-returning students | Returning students (promoted) | | All teams | Event definitions (national catalog) | -| All event rankings | Notes (including meeting notes by title) | +| All event rankings | Notes (including meeting notes by title; notes for removed students are soft-deleted) | | All meeting history attendance snapshots | Database backup under `Data/backups/` | | Event occurrences (when checked) | |