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,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"
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user