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> 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> PreviewStudentsAsync( NotePrintRequest request, Dictionary 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 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> PreviewTeamsAsync( NotePrintRequest request, Dictionary 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> PreviewEventsAsync( NotePrintRequest request, Dictionary 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? 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, record.HtmlFragments)) ]; } private static NotePrintPage MergePage( string displayName, string template, Dictionary? imported, Dictionary entity, Dictionary chapter, IReadOnlyDictionary? 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 StudentHtmlFragments(Student student) => new(StringComparer.OrdinalIgnoreCase) { [NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings) }; private static Dictionary EventHtmlFragments(IReadOnlyList? rankings) => new(StringComparer.OrdinalIgnoreCase) { [NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings) }; private Dictionary ChapterTokens() { var settings = ChapterSettings.FromConfiguration(_configuration); return new Dictionary(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 StudentTokens(Student student) { var tokens = new Dictionary(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 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 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 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 ImportedTokens( IReadOnlyList parsed, IReadOnlyList catalog) { var imported = new Dictionary(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; } }