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>
This commit is contained in:
2026-08-30 15:38:43 -04:00
co-authored by Cursor
parent 4cfd85b902
commit 3f50d6e635
39 changed files with 2199 additions and 69 deletions
+47 -12
View File
@@ -7,7 +7,7 @@ namespace Core.Notes;
/// </summary>
public static class ImportedFieldsTable
{
public const string Heading = "## Imported fields";
public const string Heading = "## Additional fields";
public static string NormalizeValue(string? raw)
{
@@ -18,7 +18,7 @@ public static class ImportedFieldsTable
}
/// <summary>
/// Reads Field/Value rows from the Imported fields section.
/// Reads Field/Value rows from the Additional fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
@@ -50,7 +50,7 @@ public static class ImportedFieldsTable
}
/// <summary>
/// Upserts incoming fields into the Imported fields section. Incoming values win.
/// 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)
@@ -126,7 +126,7 @@ public static class ImportedFieldsTable
}
/// <summary>
/// Unique imported field names across notes, first-seen casing, sorted A–Z.
/// Unique additional-field names across notes, first-seen casing, sorted A–Z.
/// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
@@ -150,11 +150,10 @@ public static class ImportedFieldsTable
if (string.IsNullOrEmpty(markdown))
return null;
var start = IndexOfHeading(markdown);
if (start < 0)
if (!TryFindHeading(markdown, out var start, out var headingLength))
return null;
var afterHeading = start + Heading.Length;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
@@ -164,8 +163,7 @@ public static class ImportedFieldsTable
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
var start = IndexOfHeading(existingMarkdown);
if (start < 0)
if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
@@ -173,7 +171,7 @@ public static class ImportedFieldsTable
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + Heading.Length;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
@@ -199,8 +197,45 @@ public static class ImportedFieldsTable
return builder.ToString();
}
private static int IndexOfHeading(string markdown) =>
markdown.IndexOf(Heading, StringComparison.Ordinal);
/// <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)
{