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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Entity a print preset merges a note onto.
|
||||
/// </summary>
|
||||
public enum PrintEntityType
|
||||
{
|
||||
Student,
|
||||
Team,
|
||||
Event
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user