Files
chapter-organizer/Core/Notes/ImportedFieldsTable.cs
T
poprhythmandCursor 4cfd85b902 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>
2026-08-29 23:55:03 -04:00

273 lines
8.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}