Files
chapter-organizer/Core/Notes/ImportedFieldsTable.cs
T
poprhythmandCursor 3f50d6e635 feat: add a Tools page printer for note merge and print presets
Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:38:43 -04:00

308 lines
9.7 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 = "## Additional 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 Additional 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 Additional 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 additional-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;
if (!TryFindHeading(markdown, out var start, out var headingLength))
return null;
var afterHeading = start + headingLength;
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;
if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
? section
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + headingLength;
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();
}
/// <summary>
/// First <c>## … fields</c> heading (any prefix). New sections are written as <see cref="Heading"/>.
/// </summary>
private static bool TryFindHeading(string markdown, out int start, out int headingLength)
{
var index = 0;
while (index < markdown.Length)
{
var lineEnd = markdown.IndexOf('\n', index);
var end = lineEnd < 0 ? markdown.Length : lineEnd;
var line = markdown[index..end].TrimEnd('\r');
if (IsFieldsHeading(line))
{
start = index;
headingLength = line.Length;
return true;
}
if (lineEnd < 0)
break;
index = lineEnd + 1;
}
start = -1;
headingLength = 0;
return false;
}
private static bool IsFieldsHeading(string line)
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("## ", StringComparison.Ordinal)
|| trimmed.StartsWith("###", StringComparison.Ordinal))
return false;
var title = trimmed[3..].Trim();
return title.EndsWith(" fields", StringComparison.OrdinalIgnoreCase)
|| title.Equals("fields", StringComparison.OrdinalIgnoreCase);
}
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; }
}