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:
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user