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
+15
View File
@@ -35,4 +35,19 @@ public interface INoteNamingService
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle);
/// <summary>
/// Gets the title for a student note. Format: "#Student:{id}"
/// </summary>
string GetStudentNoteTitle(int studentId);
/// <summary>
/// Checks if a note title is a student note.
/// </summary>
bool IsStudentNote(string? noteTitle);
/// <summary>
/// Parses the student id from a student note title.
/// </summary>
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
}
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student field CSVs and merges them into markdown notes.
/// </summary>
public interface IStudentNotesImportService
{
StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
}
+23
View File
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
private const string StudentNotePrefix = "#Student:";
/// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
/// <inheritdoc/>
public bool IsStudentNote(string? noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
return false;
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
{
studentId = 0;
if (!IsStudentNote(noteTitle))
return false;
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
}
}
+54
View File
@@ -0,0 +1,54 @@
using Core.Models;
namespace Core.Services;
/// <summary>
/// Decides which parsed student notes should be created or updated.
/// Unchanged merges are omitted so a re-import does not write history.
/// </summary>
public static class StudentNotesImportPlan
{
/// <summary>
/// Builds persist actions from a parse result. Only matches with field changes are included.
/// </summary>
public static IReadOnlyList<StudentNotePersistAction> Create(
StudentNotesImportResult parseResult,
IReadOnlySet<int> studentIdsWithExistingNotes)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
List<StudentNotePersistAction> actions = [];
foreach (var match in parseResult.Matches)
{
if (!match.Merge.Changed)
continue;
actions.Add(new StudentNotePersistAction
{
StudentId = match.Student.Id,
Markdown = match.Merge.Markdown,
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
? StudentNotePersistKind.Update
: StudentNotePersistKind.Create
});
}
return actions;
}
}
public enum StudentNotePersistKind
{
Create,
Update
}
public class StudentNotePersistAction
{
public required int StudentId { get; init; }
public required string Markdown { get; init; }
public required StudentNotePersistKind Kind { get; init; }
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
/// </summary>
public class StudentNotesImportService : IStudentNotesImportService
{
/// <inheritdoc />
public StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, existingNotesByStudentId);
}
}