Files
chapter-organizer/WebApp/Services/NotePrintService.cs
T
poprhythmandCursor 1337d9833d 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>
2026-09-02 21:37:49 -04:00

347 lines
12 KiB
C#

using Core.Entities;
using Core.Notes;
using Core.Printing;
using Data;
using Microsoft.EntityFrameworkCore;
using WebApp.Models;
namespace WebApp.Services;
public class NotePrintService : INotePrintService
{
private readonly AppDbContext _context;
private readonly IConfiguration _configuration;
private readonly INotesService _notesService;
public NotePrintService(
AppDbContext context,
IConfiguration configuration,
INotesService notesService)
{
_context = context;
_configuration = configuration;
_notesService = notesService;
}
public async Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
NotePrintRequest request,
CancellationToken cancellationToken = default)
{
var chapter = ChapterTokens();
var template = request.TemplateMarkdown;
return request.EntityType switch
{
PrintEntityType.Student => await PreviewStudentsAsync(request, chapter, template, cancellationToken),
PrintEntityType.Team => await PreviewTeamsAsync(request, chapter, template, cancellationToken),
PrintEntityType.Event => await PreviewEventsAsync(request, chapter, template, cancellationToken),
_ => []
};
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewStudentsAsync(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Students.AsNoTracking().AsQueryable();
if (filters.Grade.HasValue)
query = query.Where(s => s.Grade == filters.Grade.Value);
if (filters.TsaYear.HasValue)
query = query.Where(s => s.TsaYear == filters.TsaYear.Value);
if (filters.IsOfficer == true)
query = query.Where(s => s.OfficerRole != null);
else if (filters.IsOfficer == false)
query = query.Where(s => s.OfficerRole == null);
var students = await query
.Include(s => s.EventRankings)
.ThenInclude(r => r.EventDefinition)
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(cancellationToken);
var notes = await _notesService.GetStudentNotesAsync(students.Select(s => s.Id));
List<(Student Student, Dictionary<string, string?> Imported)> rows = [];
foreach (var student in students)
{
notes.TryGetValue(student.Id, out var note);
var parsed = ImportedFieldsTable.ParseFields(note?.Content);
rows.Add((student, ImportedTokens(parsed, request.ImportedFieldCatalog)));
}
return FinishPreview(
request,
template,
chapter,
rows.Count == 1 ? "1 student" : $"{rows.Count} students",
rows.Select(row => new RecordMerge(
row.Student.LastNameFirstName,
row.Imported,
StudentTokens(row.Student),
StudentHtmlFragments(row.Student))));
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewTeamsAsync(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Teams
.AsNoTracking()
.Include(t => t.Event)
.Include(t => t.Captain)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(filters.TeamIdentifierContains))
{
var term = filters.TeamIdentifierContains.Trim();
query = query.Where(t => t.Identifier != null && t.Identifier.Contains(term));
}
var teams = await query
.OrderBy(t => t.Event.Name)
.ThenBy(t => t.Identifier)
.ToListAsync(cancellationToken);
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(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Events.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(filters.EventNameContains))
{
var term = filters.EventNameContains.Trim();
query = query.Where(e => e.Name.Contains(term));
}
if (filters.EventFormat.HasValue)
query = query.Where(e => e.EventFormat == filters.EventFormat.Value);
if (filters.RegionalOnly == true)
query = query.Where(e => e.ChapterEligibilityCountRegionals > 0);
else if (filters.RegionalOnly == false)
query = query.Where(e => e.ChapterEligibilityCountRegionals <= 0);
var events = await query
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
var rankings = await _context.StudentEventRanking
.AsNoTracking()
.Include(r => r.Student)
.Include(r => r.EventDefinition)
.ToListAsync(cancellationToken);
var rankingsByEventId = rankings
.GroupBy(r => r.EventDefinition.Id)
.ToDictionary(g => g.Key, g => g.ToList());
return FinishPreview(
request,
template,
chapter,
events.Count == 1 ? "1 event" : $"{events.Count} events",
events.Select(evt => new RecordMerge(
evt.Name,
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,
record.HtmlFragments))
];
}
private static NotePrintPage MergePage(
string displayName,
string template,
Dictionary<string, string?>? imported,
Dictionary<string, string?> entity,
Dictionary<string, string?> chapter,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
var map = PrintTokenMap.Build(imported, entity, chapter);
return new NotePrintPage
{
DisplayName = displayName,
Html = NoteTemplateMerger.ApplyLayout(
MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)),
htmlFragments)
};
}
private static Dictionary<string, string> StudentHtmlFragments(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings)
};
private static Dictionary<string, string> EventHtmlFragments(IReadOnlyList<StudentEventRanking>? rankings) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings)
};
private Dictionary<string, string?> ChapterTokens()
{
var settings = ChapterSettings.FromConfiguration(_configuration);
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["Chapter.Name"] = settings.Name,
["Chapter.ShortName"] = settings.ShortName,
["Chapter.CompetitionYear"] = settings.CompetitionYear,
["Chapter.YearlyTheme"] = settings.YearlyTheme,
["Chapter.StateAbbrev"] = settings.StateAbbrev
};
}
private static Dictionary<string, string?> StudentTokens(Student student)
{
var tokens = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["FirstName"] = student.FirstName,
["LastName"] = student.LastName,
["Name"] = student.Name,
["LastNameFirstName"] = student.LastNameFirstName,
["Grade"] = student.Grade.ToString(),
["TsaYear"] = student.TsaYear.ToString(),
["Email"] = student.Email,
["PhoneNumber"] = student.PhoneNumber,
["StateId"] = student.StateId,
["RegionalId"] = student.RegionalId,
["NationalId"] = student.NationalId,
["OfficerRole"] = student.OfficerRole?.ToString()
};
foreach (var (key, value) in StudentRankTokens.FromRankings(student.EventRankings))
tokens[key] = value;
return tokens;
}
private static Dictionary<string, string?> TeamTokens(Team team)
{
var evt = team.Event;
var tokens = EventSharedTokens(evt);
tokens["Identifier"] = team.Identifier;
tokens["Name"] = team.ToString();
tokens["EventName"] = evt?.Name;
tokens["EventShortName"] = evt?.ShortName;
return tokens;
}
private static Dictionary<string, string?> EventTokens(EventDefinition evt)
{
var tokens = EventSharedTokens(evt);
tokens["Name"] = evt.Name;
tokens["ShortName"] = evt.ShortName;
tokens["LevelOfEffort"] = evt.LevelOfEffort?.ToString();
tokens["SemifinalistActivity"] = evt.SemifinalistActivity;
tokens["RegionalEvent"] = evt.RegionalEvent ? "Yes" : "No";
tokens["Documentation"] = evt.Documentation;
tokens["Notes"] = evt.Notes;
return tokens;
}
private static Dictionary<string, string?> EventSharedTokens(EventDefinition? evt) =>
new(StringComparer.OrdinalIgnoreCase)
{
["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)
};
private static Dictionary<string, string?> ImportedTokens(
IReadOnlyList<ImportedField> parsed,
IReadOnlyList<string> catalog)
{
var imported = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var name in catalog)
{
if (string.IsNullOrWhiteSpace(name))
continue;
imported[name] = parsed
.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
?.Value;
}
foreach (var field in parsed)
imported.TryAdd(field.Name, field.Value);
return imported;
}
}