feat: merge event rankings and attribute badges into the page printer
Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Printing;
|
||||
|
||||
public sealed record EventMark(
|
||||
string Symbol,
|
||||
string Label,
|
||||
string Color,
|
||||
Func<EventDefinition, bool> Applies);
|
||||
|
||||
/// <summary>
|
||||
/// Compact event-attribute marks used on the event-ranking index chip,
|
||||
/// print badges, and the shared legend.
|
||||
/// </summary>
|
||||
public static class EventAttributeMarks
|
||||
{
|
||||
public const string LevelOfEffort1 = "○";
|
||||
public const string LevelOfEffort2 = "◐";
|
||||
public const string LevelOfEffort3 = "⬤";
|
||||
public const string Individual = "ⓘ";
|
||||
public const string OnSite = "ⓐ";
|
||||
public const string Regional = "ⓡ";
|
||||
public const string Presubmission = "ⓟ";
|
||||
|
||||
public static readonly IReadOnlyList<EventMark> LegendItems =
|
||||
[
|
||||
new(LevelOfEffort1, "Level of Effort: 1", "#757575", e => e.LevelOfEffort == 1),
|
||||
new(LevelOfEffort2, "Level of Effort: 2", "#616161", e => e.LevelOfEffort == 2),
|
||||
new(LevelOfEffort3, "Level of Effort: 3", "#424242", e => e.LevelOfEffort == 3),
|
||||
new(Individual, "Individual Event", "#9c27b0", e => e.EventFormat == EventFormat.Individual),
|
||||
new(OnSite, "On-Site Activity", "#ff9800", e => e.OnSiteActivity),
|
||||
new(Regional, "Regional Event", "#2196f3", e => e.RegionalEvent),
|
||||
new(Presubmission, "Presubmission", "#4caf50", e => e.Presubmission)
|
||||
];
|
||||
|
||||
public static string For(EventDefinition? evt)
|
||||
{
|
||||
if (evt is null)
|
||||
return string.Empty;
|
||||
|
||||
return string.Join(
|
||||
" ",
|
||||
LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Rank labels and colors shared by the ranking index, print badges, and legend.
|
||||
/// </summary>
|
||||
public static class EventRankLegend
|
||||
{
|
||||
public static readonly IReadOnlyList<(int Rank, string Label)> Items =
|
||||
[
|
||||
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
|
||||
.Select(rank => (rank, Ordinal(rank)))
|
||||
];
|
||||
|
||||
public static string Ordinal(int num)
|
||||
{
|
||||
if (num <= 0)
|
||||
return num.ToString();
|
||||
|
||||
switch (num % 100)
|
||||
{
|
||||
case 11:
|
||||
case 12:
|
||||
case 13:
|
||||
return num + "th";
|
||||
}
|
||||
|
||||
return (num % 10) switch
|
||||
{
|
||||
1 => num + "st",
|
||||
2 => num + "nd",
|
||||
3 => num + "rd",
|
||||
_ => num + "th"
|
||||
};
|
||||
}
|
||||
|
||||
public static string ColorHex(int rank) =>
|
||||
rank switch
|
||||
{
|
||||
1 => "#dd7e6b",
|
||||
2 => "#ea9999",
|
||||
3 => "#f9cb9c",
|
||||
4 => "#ffe599",
|
||||
5 => "#fff2cc",
|
||||
6 => "#fffaea",
|
||||
7 => "#fffefa",
|
||||
8 => "#fffefc",
|
||||
9 => "#fffffd",
|
||||
10 => "#fffffe",
|
||||
_ => "#ddd"
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace Core.Printing;
|
||||
/// 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.
|
||||
/// <c>{{RankedEvents}}</c> and <c>{{RankedStudents}}</c> become ranking-index badges.
|
||||
/// <c>{{Legend}}</c> becomes the shared attribute-mark legend.
|
||||
/// </summary>
|
||||
public static class NoteTemplateMerger
|
||||
{
|
||||
@@ -16,14 +18,29 @@ public static class NoteTemplateMerger
|
||||
|
||||
public const string AnswerSpaceToken = "AnswerSpace";
|
||||
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
|
||||
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\"></div>";
|
||||
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\"> </div>";
|
||||
|
||||
public const string LegendToken = "Legend";
|
||||
public const string LegendSentinel = "<!--tsa-legend-->";
|
||||
|
||||
public const string RankedEventsToken = "RankedEvents";
|
||||
public const string RankedStudentsToken = "RankedStudents";
|
||||
|
||||
public static readonly string[] HtmlFragmentTokens =
|
||||
[
|
||||
RankedEventsToken,
|
||||
RankedStudentsToken
|
||||
];
|
||||
|
||||
public static string HtmlFragmentSentinel(string name) => $"<!--tsa-html:{name}-->";
|
||||
|
||||
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
|
||||
|
||||
private static readonly LayoutToken[] LayoutTokens =
|
||||
[
|
||||
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
|
||||
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml)
|
||||
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml),
|
||||
new(LegendToken, LegendSentinel, PrintRankBadgeHtml.Legend())
|
||||
];
|
||||
|
||||
private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
|
||||
@@ -47,6 +64,12 @@ public static class NoteTemplateMerger
|
||||
return layout.Sentinel;
|
||||
}
|
||||
|
||||
foreach (var htmlName in HtmlFragmentTokens)
|
||||
{
|
||||
if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase))
|
||||
return HtmlFragmentSentinel(htmlName);
|
||||
}
|
||||
|
||||
return tokens.TryGetValue(key, out var value)
|
||||
? value ?? string.Empty
|
||||
: match.Value;
|
||||
@@ -56,7 +79,9 @@ public static class NoteTemplateMerger
|
||||
/// <summary>
|
||||
/// Turns layout sentinels into HTML after markdown has been rendered.
|
||||
/// </summary>
|
||||
public static string ApplyLayout(string? html)
|
||||
public static string ApplyLayout(
|
||||
string? html,
|
||||
IReadOnlyDictionary<string, string>? htmlFragments = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html))
|
||||
return string.Empty;
|
||||
@@ -68,6 +93,18 @@ public static class NoteTemplateMerger
|
||||
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var name in HtmlFragmentTokens)
|
||||
{
|
||||
var sentinel = HtmlFragmentSentinel(name);
|
||||
var fragment = htmlFragments is not null
|
||||
&& htmlFragments.TryGetValue(name, out var value)
|
||||
? value ?? string.Empty
|
||||
: string.Empty;
|
||||
html = html
|
||||
.Replace($"<p>{sentinel}</p>", fragment, StringComparison.Ordinal)
|
||||
.Replace(sentinel, fragment, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ public static class PrintFieldCatalog
|
||||
public static readonly string[] Layout =
|
||||
[
|
||||
NoteTemplateMerger.PageBreakToken,
|
||||
NoteTemplateMerger.AnswerSpaceToken
|
||||
NoteTemplateMerger.AnswerSpaceToken,
|
||||
NoteTemplateMerger.LegendToken
|
||||
];
|
||||
|
||||
public static readonly string[] Chapter =
|
||||
@@ -36,6 +37,12 @@ public static class PrintFieldCatalog
|
||||
"OfficerRole"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// <c>Rank1</c>…<c>Rank10</c> and matching <c>.ShortName</c> tokens from
|
||||
/// <see cref="StudentRankTokens"/>.
|
||||
/// </summary>
|
||||
public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames];
|
||||
|
||||
public static readonly string[] Team =
|
||||
[
|
||||
"Identifier",
|
||||
@@ -46,7 +53,8 @@ public static class PrintFieldCatalog
|
||||
"TeamSize",
|
||||
"Eligibility",
|
||||
"Description",
|
||||
"Theme"
|
||||
"Theme",
|
||||
"EventAttributes"
|
||||
];
|
||||
|
||||
public static readonly string[] Event =
|
||||
@@ -62,7 +70,9 @@ public static class PrintFieldCatalog
|
||||
"Description",
|
||||
"Theme",
|
||||
"Documentation",
|
||||
"Notes"
|
||||
"Notes",
|
||||
"EventAttributes",
|
||||
NoteTemplateMerger.RankedStudentsToken
|
||||
];
|
||||
|
||||
public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) =>
|
||||
@@ -75,5 +85,9 @@ public static class PrintFieldCatalog
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) =>
|
||||
[.. Chapter, .. EntityTokens(entityType)];
|
||||
entityType switch
|
||||
{
|
||||
PrintEntityType.Student => [.. Chapter, .. Student, .. StudentRanks],
|
||||
_ => [.. Chapter, .. EntityTokens(entityType)]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Ranking-index style badges: colored rank dot plus a short label.
|
||||
/// Student pages list ranked events; event pages list students who ranked them.
|
||||
/// </summary>
|
||||
public static class PrintRankBadgeHtml
|
||||
{
|
||||
public static string ForStudentEvents(IEnumerable<StudentEventRanking>? rankings)
|
||||
{
|
||||
if (rankings is null)
|
||||
return string.Empty;
|
||||
|
||||
var badges = rankings
|
||||
.Where(r => r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
|
||||
.OrderBy(r => r.Rank)
|
||||
.Select(EventBadge)
|
||||
.ToList();
|
||||
|
||||
return Wrap(badges);
|
||||
}
|
||||
|
||||
public static string ForEventStudents(IEnumerable<StudentEventRanking>? rankings)
|
||||
{
|
||||
if (rankings is null)
|
||||
return string.Empty;
|
||||
|
||||
var badges = rankings
|
||||
.Where(r => r.Student is not null && r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
|
||||
.OrderBy(r => r.Rank)
|
||||
.ThenByDescending(r => r.Student.Grade + r.Student.TsaYear)
|
||||
.Select(r => StudentBadge(r.Student.FirstName, r.Rank))
|
||||
.ToList();
|
||||
|
||||
return Wrap(badges);
|
||||
}
|
||||
|
||||
internal static string EventBadge(StudentEventRanking ranking)
|
||||
{
|
||||
var evt = ranking.EventDefinition;
|
||||
var label = !string.IsNullOrWhiteSpace(evt?.ShortName)
|
||||
? evt.ShortName
|
||||
: evt?.Name;
|
||||
var attributes = EventAttributeMarks.For(evt);
|
||||
var attrsHtml = string.IsNullOrEmpty(attributes)
|
||||
? string.Empty
|
||||
: $"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(attributes)}</span>";
|
||||
|
||||
return Badge(ranking.Rank, PrintTokenMap.EscapeHtml(label), attrsHtml);
|
||||
}
|
||||
|
||||
internal static string StudentBadge(string? firstName, int rank) =>
|
||||
Badge(rank, PrintTokenMap.EscapeHtml(firstName), string.Empty);
|
||||
|
||||
private static string Badge(int rank, string labelHtml, string extraHtml)
|
||||
{
|
||||
var extra = string.IsNullOrEmpty(extraHtml) ? string.Empty : $" {extraHtml}";
|
||||
return
|
||||
$"<span class=\"print-rank-badge\">" +
|
||||
$"<span class=\"print-rank-dot event-rank-{rank}\"></span> " +
|
||||
$"{labelHtml}{extra}" +
|
||||
"</span>";
|
||||
}
|
||||
|
||||
public static string Legend()
|
||||
{
|
||||
var marks = EventAttributeMarks.LegendItems
|
||||
.Select(mark =>
|
||||
"<span class=\"print-legend-mark\">" +
|
||||
$"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(mark.Symbol)}</span> " +
|
||||
PrintTokenMap.EscapeHtml(mark.Label) +
|
||||
"</span>");
|
||||
|
||||
return
|
||||
"<div class=\"print-badge-legend\">" +
|
||||
$"<div class=\"print-attr-legend\">{string.Join(" · ", marks)}</div>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
private static string Wrap(IReadOnlyList<string> badges, string separator = " ")
|
||||
{
|
||||
if (badges.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
return $"<div class=\"print-rank-badges\">{string.Join(separator, badges)}</div>";
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,16 @@ public static class PrintTokenMap
|
||||
|
||||
/// <summary>
|
||||
/// Treats substituted values as plain text so they cannot change markdown or inject HTML.
|
||||
/// Line breaks are flattened so a value cannot end a markdown table row.
|
||||
/// </summary>
|
||||
public static string Escape(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
return value
|
||||
return FlattenLines(value)
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("|", "\\|", StringComparison.Ordinal)
|
||||
.Replace("*", "\\*", StringComparison.Ordinal)
|
||||
.Replace("_", "\\_", StringComparison.Ordinal)
|
||||
.Replace("`", "\\`", StringComparison.Ordinal)
|
||||
@@ -52,4 +54,23 @@ public static class PrintTokenMap
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes a value for insertion into generated print HTML (badge labels).
|
||||
/// </summary>
|
||||
public static string EscapeHtml(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
return FlattenLines(value)
|
||||
.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("\"", """, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FlattenLines(string value) =>
|
||||
string.Join(' ',
|
||||
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Printing;
|
||||
|
||||
/// <summary>
|
||||
/// Per-student event-rank merge tokens. <c>Rank1</c> is the official event name
|
||||
/// at rank 1; <c>Rank1.ShortName</c> is the catalog short name. Every rank
|
||||
/// through <see cref="StudentEventRanking.MaxRank"/> is always a map key so a
|
||||
/// missing preference prints blank instead of leaving <c>{{Rank5}}</c> visible.
|
||||
/// </summary>
|
||||
public static class StudentRankTokens
|
||||
{
|
||||
public static string NameToken(int rank) => $"Rank{rank}";
|
||||
|
||||
public static string ShortNameToken(int rank) => $"Rank{rank}.ShortName";
|
||||
|
||||
public static string AttributesToken(int rank) => $"Rank{rank}.Attributes";
|
||||
|
||||
public static IReadOnlyList<string> AllNames { get; } =
|
||||
[
|
||||
NoteTemplateMerger.RankedEventsToken,
|
||||
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
|
||||
.SelectMany(rank => (string[])
|
||||
[
|
||||
NameToken(rank),
|
||||
ShortNameToken(rank),
|
||||
AttributesToken(rank)
|
||||
])
|
||||
];
|
||||
|
||||
public static Dictionary<string, string?> FromRankings(IEnumerable<StudentEventRanking>? rankings)
|
||||
{
|
||||
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
|
||||
{
|
||||
map[NameToken(rank)] = null;
|
||||
map[ShortNameToken(rank)] = null;
|
||||
map[AttributesToken(rank)] = null;
|
||||
}
|
||||
|
||||
if (rankings is null)
|
||||
return map;
|
||||
|
||||
foreach (var ranking in rankings)
|
||||
{
|
||||
if (ranking.Rank < 1 || ranking.Rank > StudentEventRanking.MaxRank)
|
||||
continue;
|
||||
|
||||
var evt = ranking.EventDefinition;
|
||||
map[NameToken(ranking.Rank)] = evt?.Name;
|
||||
map[ShortNameToken(ranking.Rank)] = evt?.ShortName;
|
||||
map[AttributesToken(ranking.Rank)] = EventAttributeMarks.For(evt);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user