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>
308 lines
9.7 KiB
C#
308 lines
9.7 KiB
C#
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; }
|
||
}
|