From 1337d9833db251bc36db8a9dc9389940f09c517d Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Wed, 2 Sep 2026 21:37:49 -0400 Subject: [PATCH] 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 --- Core/Printing/MarkdownTableStencil.cs | 108 ++++++++++++++++ Core/Printing/PrintFieldCatalog.cs | 8 +- Tests/Printing/MarkdownTableStencil_Tests.cs | 129 +++++++++++++++++++ Tests/Printing/PrintFieldCatalog_Tests.cs | 6 + WebApp/Services/NotePrintService.cs | 89 ++++++++++--- docs/instructions/page-printer.md | 6 +- 6 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 Core/Printing/MarkdownTableStencil.cs create mode 100644 Tests/Printing/MarkdownTableStencil_Tests.cs diff --git a/Core/Printing/MarkdownTableStencil.cs b/Core/Printing/MarkdownTableStencil.cs new file mode 100644 index 0000000..b93c264 --- /dev/null +++ b/Core/Printing/MarkdownTableStencil.cs @@ -0,0 +1,108 @@ +namespace Core.Printing; + +/// +/// Splits a markdown template that contains exactly one table so a roster can +/// share one header and append merged body rows per record. +/// +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 bodies, string suffix) + { + ArgumentNullException.ThrowIfNull(bodies); + + List 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 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); +} diff --git a/Core/Printing/PrintFieldCatalog.cs b/Core/Printing/PrintFieldCatalog.cs index 2b3ef03..13963f0 100644 --- a/Core/Printing/PrintFieldCatalog.cs +++ b/Core/Printing/PrintFieldCatalog.cs @@ -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", diff --git a/Tests/Printing/MarkdownTableStencil_Tests.cs b/Tests/Printing/MarkdownTableStencil_Tests.cs new file mode 100644 index 0000000..9a18f68 --- /dev/null +++ b/Tests/Printing/MarkdownTableStencil_Tests.cs @@ -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 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; + } +} diff --git a/Tests/Printing/PrintFieldCatalog_Tests.cs b/Tests/Printing/PrintFieldCatalog_Tests.cs index 38cd95d..924c3ed 100644 --- a/Tests/Printing/PrintFieldCatalog_Tests.cs +++ b/Tests/Printing/PrintFieldCatalog_Tests.cs @@ -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")); } diff --git a/WebApp/Services/NotePrintService.cs b/WebApp/Services/NotePrintService.cs index 051fcde..768ea51 100644 --- a/WebApp/Services/NotePrintService.cs +++ b/WebApp/Services/NotePrintService.cs @@ -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> 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> 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? Imported, + Dictionary Entity, + IReadOnlyDictionary? HtmlFragments); + + private static IReadOnlyList FinishPreview( + NotePrintRequest request, + string template, + Dictionary chapter, + string combinedDisplayName, + IEnumerable 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) diff --git a/docs/instructions/page-printer.md b/docs/instructions/page-printer.md index ea52c8d..a496488 100644 --- a/docs/instructions/page-printer.md +++ b/docs/instructions/page-printer.md @@ -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. \ No newline at end of file