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

Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
+48
View File
@@ -0,0 +1,48 @@
using Core.Entities;
using Core.Notes;
namespace Core.Models;
/// <summary>
/// Result of parsing a student notes field CSV.
/// </summary>
public class StudentNotesImportResult
{
public List<StudentNotesImportMatch> Matches { get; set; } = [];
public List<StudentNotesImportIssue> Issues { get; set; } = [];
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public IReadOnlyList<string> FieldNames { get; set; } = [];
public bool IsSuccess => Errors.Count == 0;
public int StudentsWithChanges => Matches.Count(m => m.Merge.Changed);
}
public class StudentNotesImportMatch
{
public required Student Student { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public List<ImportedField> IncomingFields { get; set; } = [];
public required ImportedFieldsMergeResult Merge { get; set; }
}
public class StudentNotesImportIssue
{
public int RowNumber { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
+272
View File
@@ -0,0 +1,272 @@
using System.Text;
namespace Core.Notes;
/// <summary>
/// Parses and upserts a generic Field/Value markdown table under a stable heading.
/// </summary>
public static class ImportedFieldsTable
{
public const string Heading = "## Imported fields";
public static string NormalizeValue(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Equals("x", StringComparison.OrdinalIgnoreCase))
return "Yes";
return value;
}
/// <summary>
/// Reads Field/Value rows from the Imported fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
var section = ExtractSection(markdown);
if (string.IsNullOrWhiteSpace(section))
return [];
List<ImportedField> fields = [];
foreach (var rawLine in section.Split('\n'))
{
var line = rawLine.Trim();
if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal))
continue;
var cells = SplitTableCells(line);
if (cells.Length < 2)
continue;
var field = UnescapeCell(cells[0]);
var value = UnescapeCell(cells[1]);
if (field.Equals("Field", StringComparison.OrdinalIgnoreCase)
&& value.Equals("Value", StringComparison.OrdinalIgnoreCase))
continue;
fields.Add(new ImportedField(field, value));
}
return fields;
}
/// <summary>
/// Upserts incoming fields into the Imported fields section. Incoming values win.
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
/// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
{
var existing = ParseFields(existingMarkdown);
var merged = existing.ToList();
List<ImportedFieldChange> changes = [];
foreach (var incomingField in incoming)
{
var name = incomingField.Name.Trim();
if (string.IsNullOrEmpty(name))
continue;
var value = NormalizeValue(incomingField.Value);
var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
merged.Add(new ImportedField(name, value));
changes.Add(new ImportedFieldChange
{
Field = name,
PreviousValue = null,
NewValue = value,
IsNew = true
});
continue;
}
var current = merged[index];
if (string.Equals(current.Value, value, StringComparison.Ordinal))
continue;
merged[index] = new ImportedField(current.Name, value);
changes.Add(new ImportedFieldChange
{
Field = current.Name,
PreviousValue = current.Value,
NewValue = value,
IsNew = false
});
}
var section = FormatSection(merged);
var markdown = ReplaceSection(existingMarkdown, section);
return new ImportedFieldsMergeResult
{
Markdown = markdown,
Changed = changes.Count > 0,
Changes = changes
};
}
public static string FormatSection(IReadOnlyList<ImportedField> fields)
{
var builder = new StringBuilder();
builder.AppendLine(Heading);
builder.AppendLine();
builder.AppendLine("| Field | Value |");
builder.AppendLine("| --- | --- |");
foreach (var field in fields)
builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |");
return builder.ToString().TrimEnd() + Environment.NewLine;
}
public static string? GetFieldValue(string? markdown, string fieldName)
{
var field = ParseFields(markdown)
.FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
return field?.Value;
}
/// <summary>
/// Unique imported field names across notes, first-seen casing, sorted A–Z.
/// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
Dictionary<string, string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (var markdown in markdowns)
{
foreach (var field in ParseFields(markdown))
{
var name = field.Name.Trim();
if (name.Length == 0)
continue;
names.TryAdd(name, name);
}
}
return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
}
private static string? ExtractSection(string? markdown)
{
if (string.IsNullOrEmpty(markdown))
return null;
var start = IndexOfHeading(markdown);
if (start < 0)
return null;
var afterHeading = start + Heading.Length;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
private static string ReplaceSection(string? existingMarkdown, string section)
{
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
var start = IndexOfHeading(existingMarkdown);
if (start < 0)
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
? section
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + Heading.Length;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(before))
{
builder.Append(before);
builder.AppendLine();
builder.AppendLine();
}
builder.Append(section.TrimEnd());
builder.AppendLine();
if (!string.IsNullOrEmpty(after))
{
builder.AppendLine();
builder.Append(after.TrimEnd());
builder.AppendLine();
}
return builder.ToString();
}
private static int IndexOfHeading(string markdown) =>
markdown.IndexOf(Heading, StringComparison.Ordinal);
private static int FindNextHeading(string markdown, int startIndex)
{
var index = startIndex;
while (index < markdown.Length)
{
var lineStart = markdown.IndexOf('\n', index);
if (lineStart < 0)
return -1;
lineStart++;
if (lineStart < markdown.Length && markdown[lineStart] == '#' &&
lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' &&
markdown[lineStart + 2] == ' ')
return lineStart;
index = lineStart;
}
return -1;
}
/// <summary>
/// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
/// value (e.g. <c>| Teacher Rec 3 | |</c>) is kept instead of dropped.
/// </summary>
private static string[] SplitTableCells(string line)
{
var parts = line.Split('|', StringSplitOptions.TrimEntries);
var start = 0;
var length = parts.Length;
if (length > 0 && parts[0].Length == 0)
{
start = 1;
length--;
}
if (length > 0 && parts[start + length - 1].Length == 0)
length--;
return length <= 0 ? [] : parts[start..(start + length)];
}
private static string EscapeCell(string value) => value.Replace("|", "\\|");
private static string UnescapeCell(string value) => value.Replace("\\|", "|");
}
public record ImportedField(string Name, string Value);
public class ImportedFieldsMergeResult
{
public required string Markdown { get; init; }
public bool Changed { get; init; }
public List<ImportedFieldChange> Changes { get; init; } = [];
}
public class ImportedFieldChange
{
public required string Field { get; init; }
public string? PreviousValue { get; init; }
public required string NewValue { get; init; }
public bool IsNew { get; init; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace Core.Notes;
/// <summary>
/// Default imported-field names used for Students index columns and the CSV template.
/// </summary>
public static class StudentNoteFieldDefaults
{
public static readonly string[] IndexColumns =
[
"Interview Time",
"Application",
"Club Permission Slip"
];
}
+29
View File
@@ -0,0 +1,29 @@
using Core.Entities;
using FuzzySharp;
namespace Core.Parsers;
/// <summary>
/// Fuzzy-matches a CSV or pasted name to existing students.
/// </summary>
public static class FuzzyStudentMatcher
{
public const int MatchThreshold = 90;
public static (Student Student, int Score)? Find(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: Score(s, name)))
.Where(x => x.Score >= MatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
public static int Score(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
}
+1 -19
View File
@@ -6,7 +6,6 @@ namespace Core.Parsers;
public class StudentEventRankingParser : CsvParserBase public class StudentEventRankingParser : CsvParserBase
{ {
public const int StudentMatchThreshold = 90;
public const int EventMatchThreshold = 70; public const int EventMatchThreshold = 70;
public const int EventAmbiguityGap = 8; public const int EventAmbiguityGap = 8;
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
if (string.IsNullOrEmpty(name)) if (string.IsNullOrEmpty(name))
continue; continue;
var studentMatch = FindStudent(students, name); var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null) if (studentMatch is null)
{ {
result.Issues.Add(new StudentEventRankingIssue result.Issues.Add(new StudentEventRankingIssue
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
return result; return result;
} }
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
.Where(x => x.Score >= StudentMatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
private static int ScoreStudent(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName) private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
{ {
var scored = events var scored = events
+63
View File
@@ -0,0 +1,63 @@
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Builds a starter CSV for <c>/students/import</c> with roster columns plus leftover note fields.
/// </summary>
public static class StudentImportCsvTemplate
{
public static readonly string[] RosterHeaders =
[
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID"
];
/// <summary>
/// Returns a CSV with a header row and one example data row.
/// </summary>
public static string Build(IEnumerable<string>? leftoverFieldNames = null)
{
List<string> leftovers = leftoverFieldNames?
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Where(name => !StudentNotesFieldParser.IsReservedHeader(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
if (leftovers.Count == 0)
leftovers = [.. StudentNoteFieldDefaults.IndexColumns];
var headers = RosterHeaders.Concat(leftovers).ToArray();
var values = headers.Select(ExampleValue).ToArray();
return $"{ToCsvRow(headers)}{Environment.NewLine}{ToCsvRow(values)}{Environment.NewLine}";
}
private static string ExampleValue(string header) => header switch
{
"Student Name" => "Last, First",
"Grade" => "9",
"TSA year" => "1st",
"Interview Time" => "3:20-3:35",
_ when ContainsIgnoreCase(header, "Application")
|| ContainsIgnoreCase(header, "Permission") => "x",
_ => string.Empty
};
private static bool ContainsIgnoreCase(string value, string part) =>
value.Contains(part, StringComparison.OrdinalIgnoreCase);
private static string ToCsvRow(IEnumerable<string> cells) =>
string.Join(",", cells.Select(EscapeCsv));
private static string EscapeCsv(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+164
View File
@@ -0,0 +1,164 @@
using Core.Entities;
using Core.Models;
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
/// </summary>
public class StudentNotesFieldParser : CsvParserBase
{
private static readonly HashSet<string> ReservedHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID",
"Officer",
"TOTAL # OF EVENTS"
};
public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{
}
public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
/// <summary>
/// Roster and ranking columns that must not become imported note fields.
/// </summary>
public static bool IsReservedHeader(string? header)
{
if (string.IsNullOrWhiteSpace(header))
return true;
var trimmed = header.Trim();
if (ReservedHeaders.Contains(trimmed))
return true;
return int.TryParse(trimmed, out var rank)
&& rank >= 1
&& rank <= StudentEventRanking.MaxRank;
}
/// <summary>
/// Leftover field names from a header row after reserved columns are removed.
/// </summary>
public static List<string> GetLeftoverFieldNames(IEnumerable<string?> headers) =>
headers
.Where(h => !IsReservedHeader(h))
.Select(h => h!.Trim())
.ToList();
/// <summary>
/// Reads the header row and returns leftover field names without processing data rows.
/// </summary>
public List<string> PeekLeftoverFieldNames()
{
CsvReader.Read();
CsvReader.ReadHeader();
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
}
public StudentNotesImportResult Parse(
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var result = new StudentNotesImportResult();
CsvReader.Read();
CsvReader.ReadHeader();
if (CsvReader.HeaderRecord is null ||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
{
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord);
result.FieldNames = fieldNames;
if (fieldNames.Count == 0)
result.Warnings.Add("No leftover field columns were found besides roster and ranking columns.");
Dictionary<int, PendingStudentFields> pendingByStudentId = [];
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentNotesImportIssue
{
RowNumber = rowNumber,
RawStudentName = name,
Message = $"No student matched '{name}'."
});
continue;
}
var (student, score) = studentMatch.Value;
if (!pendingByStudentId.TryGetValue(student.Id, out var pending))
{
pending = new PendingStudentFields(student);
pendingByStudentId[student.Id] = pending;
}
pending.RawStudentName = name;
pending.RowNumber = rowNumber;
pending.StudentScore = score;
foreach (var fieldName in fieldNames)
{
var raw = CsvReader.GetField(fieldName);
pending.Fields[fieldName.Trim()] = raw ?? string.Empty;
}
}
foreach (var pending in pendingByStudentId.Values)
{
List<ImportedField> incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))];
existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown);
var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming);
result.Matches.Add(new StudentNotesImportMatch
{
Student = pending.Student,
RawStudentName = pending.RawStudentName,
RowNumber = pending.RowNumber,
StudentScore = pending.StudentScore,
IncomingFields = incoming,
Merge = merge
});
}
if (result.Matches.Count == 0 && result.Errors.Count == 0)
result.Warnings.Add("No students were matched from the CSV.");
return result;
}
private sealed class PendingStudentFields(Student student)
{
public Student Student { get; } = student;
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
+15
View File
@@ -35,4 +35,19 @@ public interface INoteNamingService
/// <param name="noteTitle">The note title to check</param> /// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns> /// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle); bool IsMeetingNote(string noteTitle);
/// <summary>
/// Gets the title for a student note. Format: "#Student:{id}"
/// </summary>
string GetStudentNoteTitle(int studentId);
/// <summary>
/// Checks if a note title is a student note.
/// </summary>
bool IsStudentNote(string? noteTitle);
/// <summary>
/// Parses the student id from a student note title.
/// </summary>
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
} }
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student field CSVs and merges them into markdown notes.
/// </summary>
public interface IStudentNotesImportService
{
StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
}
+23
View File
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
{ {
private const string PageNotePrefix = "#"; private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes"; private const string MeetingNotePrefix = "#Meeting Notes";
private const string StudentNotePrefix = "#Student:";
/// <inheritdoc/> /// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate) public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal); return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
} }
/// <inheritdoc/>
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
/// <inheritdoc/>
public bool IsStudentNote(string? noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
return false;
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
{
studentId = 0;
if (!IsStudentNote(noteTitle))
return false;
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
}
} }
+54
View File
@@ -0,0 +1,54 @@
using Core.Models;
namespace Core.Services;
/// <summary>
/// Decides which parsed student notes should be created or updated.
/// Unchanged merges are omitted so a re-import does not write history.
/// </summary>
public static class StudentNotesImportPlan
{
/// <summary>
/// Builds persist actions from a parse result. Only matches with field changes are included.
/// </summary>
public static IReadOnlyList<StudentNotePersistAction> Create(
StudentNotesImportResult parseResult,
IReadOnlySet<int> studentIdsWithExistingNotes)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
List<StudentNotePersistAction> actions = [];
foreach (var match in parseResult.Matches)
{
if (!match.Merge.Changed)
continue;
actions.Add(new StudentNotePersistAction
{
StudentId = match.Student.Id,
Markdown = match.Merge.Markdown,
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
? StudentNotePersistKind.Update
: StudentNotePersistKind.Create
});
}
return actions;
}
}
public enum StudentNotePersistKind
{
Create,
Update
}
public class StudentNotePersistAction
{
public required int StudentId { get; init; }
public required string Markdown { get; init; }
public required StudentNotePersistKind Kind { get; init; }
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
/// </summary>
public class StudentNotesImportService : IStudentNotesImportService
{
/// <inheritdoc />
public StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, existingNotesByStudentId);
}
}
+179
View File
@@ -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" }));
}
}
@@ -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));
}
}
@@ -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<int, string?> { [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<int, string?> { [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<int, string?> { [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<Core.Entities.Student> students,
IReadOnlyDictionary<int, string?>? 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<int, string?>());
}
}
+28
View File
@@ -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);
}
}
@@ -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<int> 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<int>());
Assert.That(actions, Is.Empty);
}
private static StudentNotesImportMatch Match(Core.Entities.Student student, ImportedFieldsMergeResult merge) =>
new()
{
Student = student,
Merge = merge
};
}
+1
View File
@@ -25,6 +25,7 @@
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script> <script src="js/markdownTablePaste.js"></script>
<script src="js/downloadFile.js"></script>
<script src="js/login.js"></script> <script src="js/login.js"></script>
</body> </body>
@@ -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<EventCatalogImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Catalog"
Description="Add new event definitions from CSV. Existing event names are skipped."
Icon="@AppIcons.Events"
ShowBackButton="true"
BackButtonUrl="/events" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Event</code>, <code>Team Size</code>, <code>State Count</code>.
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
<code>Documentation</code>, <code>State Presubmission</code>,
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_events is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_events.Length event(s) parsed.
@_newEventCount new, @_existingEventCount already in the database.
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _newEventCount == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@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;
}
}
@@ -4,6 +4,7 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using WebApp.Authentication
@inject AppDbContext Context @inject AppDbContext Context
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -13,6 +14,11 @@
<MudTooltip Text="Create New"> <MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip> </MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
</MudTooltip>
</AuthorizeView>
<MudTooltip Text="Printable Descriptions"> <MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip> </MudTooltip>
@@ -0,0 +1,104 @@
@using Core.Services
@using PSC.Blazor.Components.MarkdownEditor
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject MarkdownTablePasteService MarkdownTablePasteService
@inject ISnackbar Snackbar
<MudText Typo="Typo.h5" Class="mb-4">Notes</MudText>
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (ReadOnly)
{
@if (string.IsNullOrWhiteSpace(_content))
{
<MudText Class="mud-text-secondary">No notes yet.</MudText>
}
else
{
@((MarkupString)MarkdownHelper.ToHtml(_content))
}
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Imported fields appear in a table and can be edited.</MudText>
<MarkdownEditor Value="@_content"
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..."
AutoSaveEnabled="false"
NativeSpellChecker="false" />
}
@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);
}
}
@@ -82,6 +82,10 @@
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
</MudPaper>
@code { @code {
private Student? student; private Student? student;
@@ -52,6 +52,11 @@
</MudSelect> </MudSelect>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
</MudPaper>
</MudItem>
</MudGrid> </MudGrid>
</EditForm> </EditForm>
@@ -73,6 +78,7 @@
private FormChangeTracker? _formChangeTracker; private FormChangeTracker? _formChangeTracker;
private EditContext? _editContext; private EditContext? _editContext;
private List<string> _validationErrors = new(); private List<string> _validationErrors = new();
private StudentNotePanel? _notePanel;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -120,6 +126,8 @@
try try
{ {
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();
if (_notePanel is not null)
await _notePanel.SaveAsync();
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success); Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation(); _formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/students"); NavigationManager.NavigateTo(ReturnUrl ?? "/students");
@@ -2,17 +2,40 @@
@attribute [Authorize] @attribute [Authorize]
@implements IAsyncDisposable @implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@inject AppDbContext Context @inject AppDbContext Context
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject INotesService NotesService
@inject IConfiguration Configuration
@inject IJSRuntime JSRuntime
@using Core.Notes
@using Core.Parsers
@using WebApp.Services
<PageHeader Title="Students"> <PageHeader Title="Students">
<ActionButtons> <ActionButtons>
<MudTooltip Text="Create New"> <MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip> </MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudButtonGroup Variant="Variant.Outlined">
<MudTooltip Text="Add new students from CSV. Existing names are skipped; leftover columns merge into student notes.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/students/import">Import</MudButton>
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.ArrowDropDown"
AriaLabel="More import actions"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight">
<MudMenuItem Icon="@Icons.Material.Filled.Download"
OnClick="DownloadStudentImportTemplate">
Download CSV template
</MudMenuItem>
</MudMenu>
</MudButtonGroup>
</AuthorizeView>
<MudTooltip Text="Event Rankings"> <MudTooltip Text="Event Rankings">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton> <MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
</MudTooltip> </MudTooltip>
@@ -24,6 +47,7 @@
<MudPaper Elevation="2" Class="pa-3 pa-md-6"> <MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudDataGrid T="Student" <MudDataGrid T="Student"
@key="NoteFieldColumnsKey"
ServerData="ServerReload" ServerData="ServerReload"
@ref="_dataGrid" @ref="_dataGrid"
Filterable="true" Filterable="true"
@@ -65,6 +89,15 @@
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear) <span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
</CellTemplate> </CellTemplate>
</PropertyColumn> </PropertyColumn>
@foreach (var field in _noteFieldColumns)
{
var fieldName = field;
<TemplateColumn Title="@fieldName" Sortable="false" Filterable="false">
<CellTemplate>
@GetNoteField(context.Item.Id, fieldName)
</CellTemplate>
</TemplateColumn>
}
</Columns> </Columns>
<PagerContent> <PagerContent>
<MudDataGridPager T="Student"></MudDataGridPager> <MudDataGridPager T="Student"></MudDataGridPager>
@@ -77,12 +110,20 @@
private bool _isLoading = true; private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false; private bool _isDisposed = false;
private List<string> _noteFieldColumns = [];
private Dictionary<int, string?> _noteContentByStudentId = [];
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
protected override void OnInitialized() protected override void OnInitialized()
{ {
_cancellationTokenSource = new CancellationTokenSource(); _cancellationTokenSource = new CancellationTokenSource();
} }
protected override void OnParametersSet()
{
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
}
private async Task<GridData<Student>> ServerReload(GridState<Student> state) private async Task<GridData<Student>> ServerReload(GridState<Student> state)
{ {
if (_isDisposed) if (_isDisposed)
@@ -104,6 +145,9 @@
var totalItems = await query.CountAsync(cancellationToken); var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(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<Student> return new GridData<Student>
{ {
TotalItems = totalItems, TotalItems = totalItems,
@@ -160,6 +204,7 @@
return; return;
} }
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
Context.Students.Remove(studentToDelete); Context.Students.Remove(studentToDelete);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
@@ -203,4 +248,42 @@
} }
await ValueTask.CompletedTask; 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;
}
} }
@@ -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<StudentImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Students"
Description="Add new students from CSV. Existing first+last names are skipped; leftover columns merge into student notes."
ShowBackButton="true"
BackButtonUrl="/students" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Student Name</code>, <code>Grade</code>, <code>TSA year</code>.
Optional IDs are saved on new students. Every other column is merged into that student's notes.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_students is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_students.Length student(s) parsed.
@_newStudentCount new, @_existingStudentCount already in the database.
</MudAlert>
@if (_leftoverFieldNames.Count > 0)
{
<MudText Typo="Typo.body2">Leftover note fields: @string.Join(", ", _leftoverFieldNames)</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">No leftover note fields in this file.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _students.Length == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private Student[]? _students;
private List<string> _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<string> 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;
}
}
+103 -5
View File
@@ -1,5 +1,6 @@
@page "/settings/chapter" @page "/settings/chapter"
@attribute [Authorize(Roles = AuthRoles.Administrator)] @attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using WebApp.Authentication @using WebApp.Authentication
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@@ -7,12 +8,13 @@
@using Core.Models @using Core.Models
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter @inject IChapterSettingsWriter ChapterSettingsWriter
@inject INotesService NotesService
@rendermode InteractiveServer @rendermode InteractiveServer
<PageHeader <PageHeader
Title="Chapter Settings" Title="Chapter Settings"
Description="Configure chapter information. Changes take effect on next application restart." /> 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." />
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
@@ -90,6 +92,41 @@
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
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).
</MudText>
@if (_availableFields.Count > 0)
{
<MudText Typo="Typo.subtitle2" Class="mb-2">Fields in student notes</MudText>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" Class="mb-4">
@foreach (var field in _availableFields)
{
var selected = IsFieldSelected(field);
<MudChip T="string"
Size="Size.Small"
Color="@(selected ? Color.Primary : Color.Default)"
Variant="@(selected ? Variant.Filled : Variant.Outlined)"
OnClick="() => ToggleField(field)">
@field
</MudChip>
}
</MudStack>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-3">
No imported fields found in student notes yet.
</MudText>
}
<MudTextField @bind-Value="_noteFieldsText"
Label="Imported field columns"
Variant="Variant.Outlined"
Lines="5"
HelperText="Example: Interview Time" />
</MudPaper>
<MudPaper Class="pa-6"> <MudPaper Class="pa-6">
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
@@ -125,14 +162,52 @@
@code { @code {
private Models.ChapterSettings? _settings; private Models.ChapterSettings? _settings;
private string _noteFieldsText = string.Empty;
private IReadOnlyList<string> _availableFields = [];
private bool _isSaving; private bool _isSaving;
private string? _statusMessage; private string? _statusMessage;
private Severity _statusSeverity = Severity.Success; private Severity _statusSeverity = Severity.Success;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized() protected override void OnInitialized()
{ {
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>() _cancellationTokenSource = new CancellationTokenSource();
?? new Models.ChapterSettings(); _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<string> 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() private async Task SaveSettings()
@@ -144,18 +219,41 @@
try try
{ {
await ChapterSettingsWriter.WriteAsync(_settings); _settings.StudentIndexNoteFields = [.. SelectedFields()];
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; 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; _statusSeverity = Severity.Success;
} }
catch (OperationCanceledException)
{
}
catch (Exception ex) catch (Exception ex)
{ {
if (_isDisposed)
return;
_statusMessage = $"Error saving settings: {ex.Message}"; _statusMessage = $"Error saving settings: {ex.Message}";
_statusSeverity = Severity.Error; _statusSeverity = Severity.Error;
} }
finally finally
{ {
if (!_isDisposed)
_isSaving = false; _isSaving = false;
} }
} }
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
} }
-115
View File
@@ -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
<PageTitle>Import Data</PageTitle>
<h1>Import Data</h1>
<h3>Events</h3>
<InputFile OnChange="UploadEvents"></InputFile>
<text>@_events?.Length Events</text>
<button class="btn btn-primary" @onclick="SaveEvents">Save to Database</button>
<br/>
<h3>Students</h3>
<InputFile OnChange="UploadStudents"></InputFile>
<text>@_students?.Length Students</text>
<button class="btn btn-primary" @onclick="SaveStudents">Save to Database</button>
@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<StreamReader> 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;
}
}
}
+24
View File
@@ -1,4 +1,6 @@
using Core.Models; using Core.Models;
using Core.Notes;
using Microsoft.Extensions.Configuration;
namespace WebApp.Models; namespace WebApp.Models;
@@ -51,4 +53,26 @@ public class ChapterSettings
/// School level for the chapter (null = import both MS and HS events) /// School level for the chapter (null = import both MS and HS events)
/// </summary> /// </summary>
public SchoolLevel? SchoolLevel { get; set; } public SchoolLevel? SchoolLevel { get; set; }
/// <summary>
/// Field names from student note <c>## Imported fields</c> tables to show as Students index columns.
/// </summary>
public List<string> StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns];
public static ChapterSettings FromConfiguration(IConfiguration configuration)
{
var settings = configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
?? new ChapterSettings();
settings.StudentIndexNoteFields = ReadIndexNoteFields(configuration);
return settings;
}
public static List<string> ReadIndexNoteFields(IConfiguration configuration)
{
var fields = configuration.GetSection("ChapterSettings:StudentIndexNoteFields").Get<List<string>>();
if (fields is null)
return [.. StudentNoteFieldDefaults.IndexColumns];
return [.. fields.Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim())];
}
} }
+12 -5
View File
@@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using Serilog; using Serilog;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using Core.Notes;
using VisNetwork.Blazor; using VisNetwork.Blazor;
using WebApp; using WebApp;
using WebApp.Authentication; using WebApp.Authentication;
@@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath))
var baseConfig = File.ReadAllText(baseAppSettingsPath); var baseConfig = File.ReadAllText(baseAppSettingsPath);
var baseDoc = JsonDocument.Parse(baseConfig); var baseDoc = JsonDocument.Parse(baseConfig);
var templateSettings = new Dictionary<string, object?>(); JsonObject templateSettings = [];
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings)) if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
{ {
templateSettings["ChapterSettings"] = JsonSerializer.Deserialize<object>(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)) if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings))
{ {
templateSettings["ValidationSettings"] = JsonSerializer.Deserialize<object>(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 WriteIndented = true
}); });
@@ -206,6 +211,8 @@ builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Servic
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>(); builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>(); builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>(); builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
builder.Services.Configure<StateScheduleHandoutOptions>( builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
+23 -11
View File
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using WebApp.Models; using WebApp.Models;
namespace WebApp.Services; namespace WebApp.Services;
@@ -8,6 +9,11 @@ namespace WebApp.Services;
/// </summary> /// </summary>
public class ChapterSettingsWriter : IChapterSettingsWriter public class ChapterSettingsWriter : IChapterSettingsWriter
{ {
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<ChapterSettingsWriter> _logger; private readonly ILogger<ChapterSettingsWriter> _logger;
@@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter
var appSettingsPath = GetAppSettingsPath(); var appSettingsPath = GetAppSettingsPath();
var dataDir = Path.GetDirectoryName(appSettingsPath); var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir)) if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir); Directory.CreateDirectory(dataDir);
}
Dictionary<string, object?> root; JsonObject root;
if (File.Exists(appSettingsPath)) if (File.Exists(appSettingsPath))
{ {
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken); var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson) root = JsonNode.Parse(existingJson) as JsonObject ?? [];
?? [];
} }
else else
{ {
root = []; 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 }; await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken);
var json = JsonSerializer.Serialize(root, options);
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken); if (_configuration is IConfigurationRoot configurationRoot)
configurationRoot.Reload();
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath); _logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
} }
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default) public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
{ {
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>() var settings = ChapterSettings.FromConfiguration(_configuration);
?? new ChapterSettings();
settings.CompetitionYear = competitionYear; settings.CompetitionYear = competitionYear;
await WriteAsync(settings, cancellationToken); await WriteAsync(settings, cancellationToken);
} }
+20
View File
@@ -22,6 +22,26 @@ public interface INotesService
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns> /// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier); Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
/// Gets the system note for a student, or null if none exists.
/// </summary>
Task<Note?> GetStudentNoteAsync(int studentId);
/// <summary>
/// Gets system notes for the given student ids, keyed by student id.
/// </summary>
Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds);
/// <summary>
/// Soft-deletes system notes for the given student ids.
/// </summary>
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
/// <summary>
/// Distinct Field names from student note <c>## Imported fields</c> tables.
/// </summary>
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Gets all history entries for a note. /// Gets all history entries for a note.
/// </summary> /// </summary>
@@ -0,0 +1,22 @@
using Core.Models;
namespace WebApp.Services;
/// <summary>
/// Persists merged student field notes.
/// </summary>
public interface IStudentNotesImportSaveService
{
Task<StudentNotesImportSaveResult> SaveAsync(
StudentNotesImportResult parseResult,
CancellationToken cancellationToken = default);
}
public class StudentNotesImportSaveResult
{
public int NotesCreated { get; set; }
public int NotesUpdated { get; set; }
public int StudentsUnchanged { get; set; }
}
+85 -5
View File
@@ -1,4 +1,5 @@
using Core.Entities; using Core.Entities;
using Core.Notes;
using Core.Services; using Core.Services;
using Data; using Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -46,8 +47,9 @@ public class NotesService : INotesService
} }
return await query return await query
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) .Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync(); .ToListAsync();
} }
@@ -67,6 +69,84 @@ public class NotesService : INotesService
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<Note?> GetStudentNoteAsync(int studentId)
{
var title = _noteNamingService.GetStudentNoteTitle(studentId);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == title && !n.IsDeleted)
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<int, Note>();
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.AsNoTracking()
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync();
Dictionary<int, Note> byStudentId = [];
foreach (var note in notes)
{
if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId))
byStudentId[studentId] = note;
}
return byStudentId;
}
public async Task SoftDeleteStudentNotesAsync(IEnumerable<int> 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<IReadOnlyList<string>> 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<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId) public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{ {
return await _context.NoteHistories return await _context.NoteHistories
@@ -253,9 +333,9 @@ public class NotesService : INotesService
{ {
return await _context.Notes return await _context.Notes
.AsNoTracking() .AsNoTracking()
.Where(n => n.IsDeleted) .Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1) .OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated .ThenByDescending(n => n.UpdatedAt)
.ToListAsync(); .ToListAsync();
} }
@@ -0,0 +1,64 @@
using Core.Models;
using Core.Services;
namespace WebApp.Services;
/// <summary>
/// Creates or updates #Student:{id} notes when imported fields actually change.
/// </summary>
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
{
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
private readonly ILogger<StudentNotesImportSaveService> _logger;
public StudentNotesImportSaveService(
INotesService notesService,
INoteNamingService noteNamingService,
ILogger<StudentNotesImportSaveService> logger)
{
_notesService = notesService;
_noteNamingService = noteNamingService;
_logger = logger;
}
/// <inheritdoc />
public async Task<StudentNotesImportSaveResult> 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;
}
}
+5
View File
@@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService
private readonly AppDbContext _context; private readonly AppDbContext _context;
private readonly IDatabaseBackupService _backupService; private readonly IDatabaseBackupService _backupService;
private readonly IChapterSettingsWriter _chapterSettingsWriter; private readonly IChapterSettingsWriter _chapterSettingsWriter;
private readonly INotesService _notesService;
private readonly ILogger<YearRolloverService> _logger; private readonly ILogger<YearRolloverService> _logger;
public YearRolloverService( public YearRolloverService(
AppDbContext context, AppDbContext context,
IDatabaseBackupService backupService, IDatabaseBackupService backupService,
IChapterSettingsWriter chapterSettingsWriter, IChapterSettingsWriter chapterSettingsWriter,
INotesService notesService,
ILogger<YearRolloverService> logger) ILogger<YearRolloverService> logger)
{ {
_context = context; _context = context;
_backupService = backupService; _backupService = backupService;
_chapterSettingsWriter = chapterSettingsWriter; _chapterSettingsWriter = chapterSettingsWriter;
_notesService = notesService;
_logger = logger; _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."); $"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); _context.Students.RemoveRange(toRemove);
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id); var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
+21
View File
@@ -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);
}
};
+23
View File
@@ -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`.
+50
View File
@@ -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.
+7 -5
View File
@@ -1,7 +1,8 @@
# Year Rollover Runbook # Year Rollover Runbook
**Created:** 2026-08-14 **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. **Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
## Prerequisites ## Prerequisites
@@ -36,9 +37,10 @@
- Type the target competition year exactly to enable **Apply rollover**. - 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. - 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. 9. **Restart the application** so the home page and printouts show the new competition year.
10. **Add new students** via `/students/create` or `/import`. 10. **Add new students** via `/students/create` or `/students/import`.
- `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners. - 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.
11. **Import the new state schedule** from the calendar import page. - 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. 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. 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) | | Non-returning students | Returning students (promoted) |
| All teams | Event definitions (national catalog) | | 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/` | | All meeting history attendance snapshots | Database backup under `Data/backups/` |
| Event occurrences (when checked) | | | Event occurrences (when checked) | |