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
+73
View File
@@ -0,0 +1,73 @@
using System.Text.RegularExpressions;
namespace Core.Printing;
/// <summary>
/// Replaces <c>{{Token}}</c> placeholders from a case-insensitive map.
/// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space after HTML conversion.
/// </summary>
public static class NoteTemplateMerger
{
public const string PageBreakToken = "PageBreak";
public const string PageBreakSentinel = "<!--tsa-page-break-->";
public const string PageBreakHtml = "<div class=\"pagebreak\"></div>";
public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\"></div>";
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens =
[
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml)
];
private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
public static string Merge(string? template, IReadOnlyDictionary<string, string> tokens)
{
if (string.IsNullOrEmpty(template))
return string.Empty;
ArgumentNullException.ThrowIfNull(tokens);
return TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value.Trim();
if (key.Length == 0)
return match.Value;
foreach (var layout in LayoutTokens)
{
if (key.Equals(layout.Name, StringComparison.OrdinalIgnoreCase))
return layout.Sentinel;
}
return tokens.TryGetValue(key, out var value)
? value ?? string.Empty
: match.Value;
});
}
/// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary>
public static string ApplyLayout(string? html)
{
if (string.IsNullOrEmpty(html))
return string.Empty;
foreach (var layout in LayoutTokens)
{
html = html
.Replace($"<p>{layout.Sentinel}</p>", layout.Html, StringComparison.Ordinal)
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal);
}
return html;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Core.Printing;
/// <summary>
/// Entity a print preset merges a note onto.
/// </summary>
public enum PrintEntityType
{
Student,
Team,
Event
}
+79
View File
@@ -0,0 +1,79 @@
namespace Core.Printing;
/// <summary>
/// Built-in merge token names. Imported student-note field names are supplied at runtime.
/// </summary>
public static class PrintFieldCatalog
{
public static readonly string[] Layout =
[
NoteTemplateMerger.PageBreakToken,
NoteTemplateMerger.AnswerSpaceToken
];
public static readonly string[] Chapter =
[
"Chapter.Name",
"Chapter.ShortName",
"Chapter.CompetitionYear",
"Chapter.YearlyTheme",
"Chapter.StateAbbrev"
];
public static readonly string[] Student =
[
"FirstName",
"LastName",
"Name",
"LastNameFirstName",
"Grade",
"TsaYear",
"Email",
"PhoneNumber",
"StateId",
"RegionalId",
"NationalId",
"OfficerRole"
];
public static readonly string[] Team =
[
"Identifier",
"Name",
"EventName",
"EventShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"Description",
"Theme"
];
public static readonly string[] Event =
[
"Name",
"ShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
"Description",
"Theme",
"Documentation",
"Notes"
];
public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) =>
entityType switch
{
PrintEntityType.Student => Student,
PrintEntityType.Team => Team,
PrintEntityType.Event => Event,
_ => []
};
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) =>
[.. Chapter, .. EntityTokens(entityType)];
}
+94
View File
@@ -0,0 +1,94 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Filter payload stored on a <see cref="PrintPreset"/>. Unused fields stay null.
/// </summary>
public class PrintPresetFilters
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public int? Grade { get; set; }
public int? TsaYear { get; set; }
/// <summary>
/// <c>true</c> officers only, <c>false</c> non-officers only, <c>null</c> any.
/// </summary>
public bool? IsOfficer { get; set; }
public string? TeamIdentifierContains { get; set; }
public string? EventNameContains { get; set; }
public EventFormat? EventFormat { get; set; }
/// <summary>
/// <c>true</c> regional only, <c>false</c> non-regional only, <c>null</c> any.
/// </summary>
public bool? RegionalOnly { get; set; }
/// <summary>
/// When true (default), each merged record starts a new printed page.
/// <c>{{PageBreak}}</c> in the template still works either way.
/// </summary>
public bool NewPagePerRecord { get; set; } = true;
public const int DefaultFontSizePt = 12;
public const int MinFontSizePt = 9;
public const int MaxFontSizePt = 18;
public const int DefaultAnswerSpaceLines = 3;
public const int MinAnswerSpaceLines = 1;
public const int MaxAnswerSpaceLines = 8;
/// <summary>
/// Body font size in points for merged pages.
/// </summary>
public int FontSizePt { get; set; } = DefaultFontSizePt;
/// <summary>
/// Ruled write-in lines for each <c>{{AnswerSpace}}</c>.
/// </summary>
public int AnswerSpaceLines { get; set; } = DefaultAnswerSpaceLines;
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static PrintPresetFilters FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new PrintPresetFilters();
var filters = JsonSerializer.Deserialize<PrintPresetFilters>(json, JsonOptions)
?? new PrintPresetFilters();
filters.ClampPrintOptions();
return filters;
}
public void ClampPrintOptions()
{
FontSizePt = Math.Clamp(FontSizePt, MinFontSizePt, MaxFontSizePt);
AnswerSpaceLines = Math.Clamp(AnswerSpaceLines, MinAnswerSpaceLines, MaxAnswerSpaceLines);
}
public static string ToTriState(bool? value) => value switch
{
true => "yes",
false => "no",
_ => "any"
};
public static bool? FromTriState(string? value) => value switch
{
"yes" => true,
"no" => false,
_ => null
};
}
+55
View File
@@ -0,0 +1,55 @@
namespace Core.Printing;
/// <summary>
/// Builds a case-insensitive token map. Fill order is imported, then entity, then chapter
/// so built-in names win over an imported column with the same name.
/// </summary>
public static class PrintTokenMap
{
public static Dictionary<string, string> Create() =>
new(StringComparer.OrdinalIgnoreCase);
public static Dictionary<string, string> Build(
IReadOnlyDictionary<string, string?>? imported,
IReadOnlyDictionary<string, string?>? entity,
IReadOnlyDictionary<string, string?>? chapter)
{
var map = Create();
Apply(map, imported);
Apply(map, entity);
Apply(map, chapter);
return map;
}
public static void Apply(IDictionary<string, string> map, IReadOnlyDictionary<string, string?>? values)
{
if (values is null)
return;
foreach (var (key, value) in values)
{
if (string.IsNullOrWhiteSpace(key))
continue;
map[key] = Escape(value);
}
}
/// <summary>
/// Treats substituted values as plain text so they cannot change markdown or inject HTML.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("*", "\\*", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal)
.Replace("`", "\\`", StringComparison.Ordinal)
.Replace("[", "\\[", StringComparison.Ordinal)
.Replace("]", "\\]", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
}