feat: stitch page-printer tables into one roster and add chapter team-count tokens

When New page per record is off, a single-table template shares one header across records. Event and team pages can print regional/state team counts and nationals eligibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 21:37:49 -04:00
co-authored by Cursor
parent 3712dba974
commit 1337d9833d
6 changed files with 327 additions and 19 deletions
+108
View File
@@ -0,0 +1,108 @@
namespace Core.Printing;
/// <summary>
/// Splits a markdown template that contains exactly one table so a roster can
/// share one header and append merged body rows per record.
/// </summary>
public sealed class MarkdownTableStencil
{
public required string Prefix { get; init; }
public required string Header { get; init; }
public required string Body { get; init; }
public required string Suffix { get; init; }
public static bool TryParse(string? template, out MarkdownTableStencil? stencil)
{
stencil = null;
if (string.IsNullOrWhiteSpace(template))
return false;
var lines = SplitLines(template);
if (!TryFindTable(lines, 0, out var headerIndex, out var bodyStart, out var bodyEnd))
return false;
if (bodyStart >= bodyEnd)
return false;
if (TryFindTable(lines, bodyEnd, out _, out _, out _))
return false;
stencil = new MarkdownTableStencil
{
Prefix = JoinLines(lines[..headerIndex]),
Header = JoinLines(lines[headerIndex..bodyStart]),
Body = JoinLines(lines[bodyStart..bodyEnd]),
Suffix = JoinLines(lines[bodyEnd..])
};
return true;
}
public string Stitch(string prefix, IEnumerable<string> bodies, string suffix)
{
ArgumentNullException.ThrowIfNull(bodies);
List<string> parts = [];
AppendPart(parts, prefix);
AppendPart(parts, Header);
foreach (var body in bodies)
AppendPart(parts, body);
AppendPart(parts, suffix);
return parts.Count == 0 ? string.Empty : string.Join('\n', parts) + "\n";
}
private static void AppendPart(List<string> parts, string? part)
{
if (string.IsNullOrEmpty(part))
return;
var trimmed = part.TrimEnd('\r', '\n');
if (trimmed.Length > 0)
parts.Add(trimmed);
}
private static bool TryFindTable(
string[] lines,
int start,
out int headerIndex,
out int bodyStart,
out int bodyEnd)
{
headerIndex = -1;
bodyStart = -1;
bodyEnd = -1;
for (var i = start; i < lines.Length - 1; i++)
{
if (!IsTableLine(lines[i]) || !IsSeparatorLine(lines[i + 1]))
continue;
headerIndex = i;
bodyStart = i + 2;
bodyEnd = bodyStart;
while (bodyEnd < lines.Length && IsTableLine(lines[bodyEnd]) && !IsSeparatorLine(lines[bodyEnd]))
bodyEnd++;
return true;
}
return false;
}
private static bool IsTableLine(string line)
{
var trimmed = line.TrimStart();
return trimmed.StartsWith('|');
}
private static bool IsSeparatorLine(string line) =>
IsTableLine(line) && line.Contains("---", StringComparison.Ordinal);
private static string[] SplitLines(string text) =>
text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
private static string JoinLines(string[] lines) =>
lines.Length == 0 ? string.Empty : string.Join('\n', lines);
}
+6 -2
View File
@@ -51,7 +51,9 @@ public static class PrintFieldCatalog
"EventShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"NationalEligibility",
"RegionalTeamCount",
"StateTeamCount",
"Description",
"Theme",
"EventAttributes"
@@ -63,7 +65,9 @@ public static class PrintFieldCatalog
"ShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"NationalEligibility",
"RegionalTeamCount",
"StateTeamCount",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
@@ -0,0 +1,129 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class MarkdownTableStencil_Tests
{
private const string Roster = """
| Name | Grade |
| --- | --- |
| {{LastNameFirstName}} | {{Grade}} |
""";
[Test]
public void TryParse_SingleTable_SplitsPrefixHeaderBodySuffix()
{
var template = """
# Rankings
| Name | 1st |
| --- | --- |
| {{LastNameFirstName}} | {{Rank1}} |
{{Legend}}
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
Assert.That(stencil!.Prefix, Does.Contain("# Rankings"));
Assert.That(stencil.Header, Does.Contain("| Name | 1st |"));
Assert.That(stencil.Header, Does.Contain("| --- | --- |"));
Assert.That(stencil.Body.Trim(), Is.EqualTo("| {{LastNameFirstName}} | {{Rank1}} |"));
Assert.That(stencil.Suffix, Does.Contain("{{Legend}}"));
}
[Test]
public void TryParse_NoTable_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("# Interview\n\n{{FirstName}}", out _), Is.False);
}
[Test]
public void TryParse_TwoTables_Fails()
{
var template = """
| Grade | Time |
| --- | --- |
| {{Grade}} | {{Interview Time}} |
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out _), Is.False);
}
[Test]
public void TryParse_HeaderOnly_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("| Name |\n| --- |\n", out _), Is.False);
}
[Test]
public void Stitch_TwoStudents_HeaderOnceAndTwoBodyRows()
{
Assert.That(MarkdownTableStencil.TryParse(Roster, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Cole, Aria"), ("Grade", "6"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Dean, Lucas"), ("Grade", "7")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Name | Grade |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
Assert.That(merged, Does.Contain("| Cole, Aria | 6 |"));
Assert.That(merged, Does.Contain("| Dean, Lucas | 7 |"));
}
[Test]
public void Stitch_AttributesRow_RepeatsPerRecord()
{
var template = """
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
| {{Rank1.Attributes}} | {{Rank2.Attributes}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Coding"), ("Rank2", "Flight"), ("Rank1.Attributes", "I"), ("Rank2.Attributes", "T"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Drone"), ("Rank2", "Robotics"), ("Rank1.Attributes", "R"), ("Rank2.Attributes", "O")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Coding | Flight |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| I | T |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| Drone | Robotics |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| R | O |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
}
private static Dictionary<string, string> Map(params (string Key, string Value)[] pairs)
{
var tokens = PrintTokenMap.Create();
foreach (var (key, value) in pairs)
tokens[key] = value;
return tokens;
}
private static int CountOccurrences(string haystack, string needle)
{
var count = 0;
var index = 0;
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}
return count;
}
}
@@ -38,7 +38,13 @@ public class PrintFieldCatalog_Tests
{
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
}
+73 -16
View File
@@ -74,16 +74,16 @@ public class NotePrintService : INotePrintService
rows.Add((student, ImportedTokens(parsed, request.ImportedFieldCatalog)));
}
return
[
.. rows.Select(row => MergePage(
return FinishPreview(
request,
template,
chapter,
rows.Count == 1 ? "1 student" : $"{rows.Count} students",
rows.Select(row => new RecordMerge(
row.Student.LastNameFirstName,
template,
row.Imported,
StudentTokens(row.Student),
chapter,
StudentHtmlFragments(row.Student)))
];
StudentHtmlFragments(row.Student))));
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewTeamsAsync(
@@ -110,10 +110,12 @@ public class NotePrintService : INotePrintService
.ThenBy(t => t.Identifier)
.ToListAsync(cancellationToken);
return
[
.. teams.Select(team => MergePage(team.ToString(), template, null, TeamTokens(team), chapter))
];
return FinishPreview(
request,
template,
chapter,
teams.Count == 1 ? "1 team" : $"{teams.Count} teams",
teams.Select(team => new RecordMerge(team.ToString(), null, TeamTokens(team), null)));
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewEventsAsync(
@@ -153,15 +155,67 @@ public class NotePrintService : INotePrintService
.GroupBy(r => r.EventDefinition.Id)
.ToDictionary(g => g.Key, g => g.ToList());
return
[
.. events.Select(evt => MergePage(
return FinishPreview(
request,
template,
chapter,
events.Count == 1 ? "1 event" : $"{events.Count} events",
events.Select(evt => new RecordMerge(
evt.Name,
template,
null,
EventTokens(evt),
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id)))));
}
private readonly record struct RecordMerge(
string DisplayName,
Dictionary<string, string?>? Imported,
Dictionary<string, string?> Entity,
IReadOnlyDictionary<string, string>? HtmlFragments);
private static IReadOnlyList<NotePrintPage> FinishPreview(
NotePrintRequest request,
string template,
Dictionary<string, string?> chapter,
string combinedDisplayName,
IEnumerable<RecordMerge> records)
{
var list = records.ToList();
if (list.Count == 0)
return [];
if (!request.Filters.NewPagePerRecord
&& MarkdownTableStencil.TryParse(template, out var stencil)
&& stencil is not null)
{
var chapterMap = PrintTokenMap.Build(null, null, chapter);
var prefix = NoteTemplateMerger.Merge(stencil.Prefix, chapterMap);
var suffix = NoteTemplateMerger.Merge(stencil.Suffix, chapterMap);
var bodies = list.Select(record =>
NoteTemplateMerger.Merge(
stencil.Body,
PrintTokenMap.Build(record.Imported, record.Entity, chapter)));
return
[
new NotePrintPage
{
DisplayName = combinedDisplayName,
Html = NoteTemplateMerger.ApplyLayout(
MarkdownHelper.ToHtml(stencil.Stitch(prefix, bodies, suffix)))
}
];
}
return
[
.. list.Select(record => MergePage(
record.DisplayName,
template,
record.Imported,
record.Entity,
chapter,
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id))))
record.HtmlFragments))
];
}
@@ -261,7 +315,10 @@ public class NotePrintService : INotePrintService
{
["EventFormat"] = evt?.EventFormat.ToString(),
["TeamSize"] = evt?.TeamSize,
["NationalEligibility"] = evt?.Eligibility,
["Eligibility"] = evt?.Eligibility,
["RegionalTeamCount"] = evt?.ChapterEligibilityCountRegionals.ToString(),
["StateTeamCount"] = evt?.ChapterEligibilityCountState.ToString(),
["Description"] = evt?.Description,
["Theme"] = evt?.Theme,
["EventAttributes"] = EventAttributeMarks.For(evt)
+5 -1
View File
@@ -1,7 +1,7 @@
# Page printer
**Created:** 2026-08-29
**Last updated:** 2026-08-30
**Last updated:** 2026-08-31
**Description:** Merge a markdown note onto students, teams, or events and print one page per match. Save the recipe as a print preset.
## Where to open it
@@ -54,6 +54,8 @@ Event pages can put the matching student list under the event name:
`{{RankedStudents}}` prints everyone who ranked that event as badges (colored rank dot and first name), same sort as **Events by Student** on the ranking index: rank, then grade + TSA year. `{{EventAttributes}}` is also available on team pages.
On event and team pages, `{{RegionalTeamCount}}` and `{{StateTeamCount}}` print how many teams the chapter may send at regionals and state. `{{NationalEligibility}}` is the nationals eligibility text (not a team count). `{{Eligibility}}` still works as the same value.
Put `{{Legend}}` where you want the attribute-mark key (same marks as the Teams printout legend).
Put `{{PageBreak}}` on its own line to force a new printed sheet **inside** one record (for example, questions on page 1 and a scoring rubric on page 2).
@@ -85,6 +87,8 @@ If the template note was removed, the filters still load. Choose another note be
**New page per record** (on by default, saved with the preset) starts each match on a new sheet. Turn it off to flow records together; `{{PageBreak}}` in the template still works. Content that is taller than one sheet continues naturally.
If that option is off and the template has **exactly one** markdown table, the header prints once and each record appends only the table body row(s). That is the roster layout (name, grade, ranks on one row). Put student/team/event tokens in those body rows. Put `{{Legend}}` above or below the table. Two tables (for example an interview sheet plus a rank table) still print as separate stacked documents. `{{RankedEvents}}`, `{{RankedStudents}}`, and `{{PageBreak}}` inside a stitched table are not supported; use them in the lines around the table, or leave New page per record on.
**Font size (pt)** (default 12) and **Answer lines** (default 3) apply to every page-printer recipe. They are saved on the preset.
If Preview finds more than 75 matches, a warning is shown first.