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:
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace Core.Parsers;
|
||||
|
||||
public class StudentEventRankingParser : CsvParserBase
|
||||
{
|
||||
public const int StudentMatchThreshold = 90;
|
||||
public const int EventMatchThreshold = 70;
|
||||
public const int EventAmbiguityGap = 8;
|
||||
|
||||
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
var studentMatch = FindStudent(students, name);
|
||||
var studentMatch = FuzzyStudentMatcher.Find(students, name);
|
||||
if (studentMatch is null)
|
||||
{
|
||||
result.Issues.Add(new StudentEventRankingIssue
|
||||
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
|
||||
{
|
||||
var ranked = students
|
||||
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
|
||||
.Where(x => x.Score >= StudentMatchThreshold)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ToList();
|
||||
|
||||
return ranked.Count == 0 ? null : ranked[0];
|
||||
}
|
||||
|
||||
private static int ScoreStudent(Student student, string name)
|
||||
{
|
||||
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
|
||||
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
|
||||
}
|
||||
|
||||
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
|
||||
{
|
||||
var scored = events
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user