diff --git a/Core/Entities/PrintPreset.cs b/Core/Entities/PrintPreset.cs index 56b234b..041f144 100644 --- a/Core/Entities/PrintPreset.cs +++ b/Core/Entities/PrintPreset.cs @@ -4,7 +4,7 @@ using Core.Printing; namespace Core.Entities; /// -/// Saved page-printer recipe: template note, entity type, and filters. Merged output is not stored. +/// Saved page-printer recipe: template markdown, entity type, and filters. Merged output is not stored. /// public class PrintPreset { @@ -14,9 +14,7 @@ public class PrintPreset [StringLength(100)] public string Name { get; set; } = null!; - public int NoteId { get; set; } - - public Note Note { get; set; } = null!; + public string TemplateMarkdown { get; set; } = string.Empty; public PrintEntityType EntityType { get; set; } diff --git a/Core/Printing/PrintFieldCatalog.cs b/Core/Printing/PrintFieldCatalog.cs index 13963f0..eeb1f76 100644 --- a/Core/Printing/PrintFieldCatalog.cs +++ b/Core/Printing/PrintFieldCatalog.cs @@ -43,6 +43,21 @@ public static class PrintFieldCatalog /// public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames]; + public static readonly string[] StudentRanks1To6 = [.. RankTokens(1, 6)]; + + public static readonly string[] StudentRanks7To10 = [.. RankTokens(7, 10)]; + + public static IReadOnlyList RankTokens(int fromRank, int toRank) => + [ + .. Enumerable.Range(fromRank, toRank - fromRank + 1) + .SelectMany(rank => (string[]) + [ + StudentRankTokens.NameToken(rank), + StudentRankTokens.ShortNameToken(rank), + StudentRankTokens.AttributesToken(rank) + ]) + ]; + public static readonly string[] Team = [ "Identifier", @@ -52,6 +67,7 @@ public static class PrintFieldCatalog "EventFormat", "TeamSize", "NationalEligibility", + "Eligibility", "RegionalTeamCount", "StateTeamCount", "Description", @@ -66,6 +82,7 @@ public static class PrintFieldCatalog "EventFormat", "TeamSize", "NationalEligibility", + "Eligibility", "RegionalTeamCount", "StateTeamCount", "LevelOfEffort", diff --git a/Data/Configurations/PrintPresetConfiguration.cs b/Data/Configurations/PrintPresetConfiguration.cs index 59c2a86..de718ed 100644 --- a/Data/Configurations/PrintPresetConfiguration.cs +++ b/Data/Configurations/PrintPresetConfiguration.cs @@ -26,10 +26,8 @@ public class PrintPresetConfiguration : IEntityTypeConfiguration .IsRequired() .HasColumnType("TEXT"); - builder.HasOne(p => p.Note) - .WithMany() - .HasForeignKey(p => p.NoteId) - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + builder.Property(p => p.TemplateMarkdown) + .IsRequired() + .HasColumnType("TEXT"); } } diff --git a/Data/Migrations/20260903010000_PrintPresetTemplateMarkdown.cs b/Data/Migrations/20260903010000_PrintPresetTemplateMarkdown.cs new file mode 100644 index 0000000..f6eb43f --- /dev/null +++ b/Data/Migrations/20260903010000_PrintPresetTemplateMarkdown.cs @@ -0,0 +1,89 @@ +using System; +using Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260903010000_PrintPresetTemplateMarkdown")] + public partial class PrintPresetTemplateMarkdown : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TemplateMarkdown", + table: "PrintPresets", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.Sql( + """ + UPDATE PrintPresets + SET TemplateMarkdown = COALESCE( + (SELECT Content FROM Notes WHERE Notes.Id = PrintPresets.NoteId), + '' + ); + """); + + migrationBuilder.Sql( + """ + CREATE TABLE "PrintPresets_new" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NOT NULL, + "TemplateMarkdown" TEXT NOT NULL, + "EntityType" TEXT NOT NULL, + "FiltersJson" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + INSERT INTO "PrintPresets_new" ("Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt") + SELECT "Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt" + FROM "PrintPresets"; + """); + + migrationBuilder.Sql("""DROP TABLE "PrintPresets";"""); + migrationBuilder.Sql("""ALTER TABLE "PrintPresets_new" RENAME TO "PrintPresets";"""); + migrationBuilder.Sql( + """CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");"""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE TABLE "PrintPresets_old" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT, + "Name" TEXT NOT NULL, + "NoteId" INTEGER NOT NULL, + "EntityType" TEXT NOT NULL, + "FiltersJson" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """); + + migrationBuilder.Sql( + """ + INSERT INTO "PrintPresets_old" ("Id", "Name", "NoteId", "EntityType", "FiltersJson", "UpdatedAt") + SELECT "Id", "Name", 0, "EntityType", "FiltersJson", "UpdatedAt" + FROM "PrintPresets"; + """); + + migrationBuilder.Sql("""DROP TABLE "PrintPresets";"""); + migrationBuilder.Sql("""ALTER TABLE "PrintPresets_old" RENAME TO "PrintPresets";"""); + migrationBuilder.Sql( + """CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");"""); + migrationBuilder.Sql( + """CREATE INDEX "IX_PrintPresets_NoteId" ON "PrintPresets" ("NoteId");"""); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index a41b284..a75c4cc 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -197,8 +197,9 @@ namespace Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); - b.Property("NoteId") - .HasColumnType("INTEGER"); + b.Property("TemplateMarkdown") + .IsRequired() + .HasColumnType("TEXT"); b.Property("UpdatedAt") .HasColumnType("TEXT"); @@ -208,8 +209,6 @@ namespace Data.Migrations b.HasIndex("Name") .IsUnique(); - b.HasIndex("NoteId"); - b.ToTable("PrintPresets"); }); @@ -503,17 +502,6 @@ namespace Data.Migrations b.Navigation("Note"); }); - modelBuilder.Entity("Core.Entities.PrintPreset", b => - { - b.HasOne("Core.Entities.Note", "Note") - .WithMany() - .HasForeignKey("NoteId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Note"); - }); - modelBuilder.Entity("Core.Entities.StudentEventRanking", b => { b.HasOne("Core.Entities.EventDefinition", "EventDefinition") diff --git a/Tests/Printing/PrintFieldCatalog_Tests.cs b/Tests/Printing/PrintFieldCatalog_Tests.cs index 924c3ed..6c450e8 100644 --- a/Tests/Printing/PrintFieldCatalog_Tests.cs +++ b/Tests/Printing/PrintFieldCatalog_Tests.cs @@ -31,6 +31,14 @@ public class PrintFieldCatalog_Tests Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3)); Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1")); Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Not.Contain("Rank1")); + Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank1")); + Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank6.Attributes")); + Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Not.Contain("Rank7")); + Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank7")); + Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank10.ShortName")); + Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Not.Contain("Rank6")); + Assert.That(PrintFieldCatalog.StudentRanks1To6, Has.Length.EqualTo(6 * 3)); + Assert.That(PrintFieldCatalog.StudentRanks7To10, Has.Length.EqualTo(4 * 3)); } [Test] @@ -39,10 +47,12 @@ 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("Eligibility")); 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("Eligibility")); 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)); diff --git a/WebApp/Components/Features/Print/PagePrinter.razor b/WebApp/Components/Features/Print/PagePrinter.razor index da281fc..9f3c0a3 100644 --- a/WebApp/Components/Features/Print/PagePrinter.razor +++ b/WebApp/Components/Features/Print/PagePrinter.razor @@ -2,20 +2,19 @@ @attribute [Authorize] @implements IAsyncDisposable @using Core.Printing -@using Core.Services @inject INotesService NotesService -@inject INoteNamingService NoteNamingService @inject INotePrintService NotePrintService @inject IPrintPresetService PrintPresetService @inject IConfiguration Configuration @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IJSRuntime JSRuntime -@inject ClipboardService ClipboardService +@inject NavigationManager NavigationManager +@inject MarkdownTablePasteService MarkdownTablePasteService
@@ -25,7 +24,7 @@ } - + - - - - + - Save as + OnClick="NewTemplate" + Disabled="@_isBusy"> + New - Update + StartIcon="@Icons.Material.Filled.Save" + OnClick="SavePreset" + Disabled="@_isBusy"> + Save Events - - @if (_templateNotes.Count == 0) - { - - Create a standalone note on Notes to use as a template. - - } - else - { - - @foreach (var note in _templateNotes) - { - @note.Title - } - - } - - - @if (_templateWarning is not null) - { - - @_templateWarning - - } @if (_entityType == PrintEntityType.Student) { - + - + - + + - + + + Insert token + + Disabled="@_isBusy"> Preview + @if (IsDirty()) + { + Unsaved changes + } @if (!_previewStale && _pages.Count > 0) { @_pages.Count page@(_pages.Count == 1 ? "" : "s") @@ -235,60 +210,61 @@ - Tokens (click to copy) - @foreach (var group in TokenGroups) + Template (Markdown) +
+ +
+ Template preview + Markdown only — tokens are not merged here. + @if (string.IsNullOrWhiteSpace(_templateMarkdown)) { - @group.Label -
- @foreach (var token in group.Tokens) - { - @FormatToken(token) - } -
+ The template will preview here as you type. + } + else + { + +
+ @((MarkupString)MarkdownHelper.ToHtml(_templateMarkdown)) +
+
}
-@if (_didPreview && _pages.Count == 0 && !_previewStale) -{ - No matching records for these filters. -} -else -{ - @for (var i = 0; i < _pages.Count; i++) - { - var page = _pages[i]; - var isLast = i == _pages.Count - 1; - var pageClass = _newPagePerRecord && !isLast - ? "note-print-page pagebreak" - : "note-print-page"; - -
- @((MarkupString)page.Html) -
-
- } -} + @code { + private const string EditorElementId = "page-printer-editor"; + private static readonly string[] HiddenEditorIcons = ["preview", "side-by-side", "fullscreen"]; + private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed; private bool _isLoading = true; private bool _isBusy; private bool _previewStale = true; private bool _didPreview; + private bool _pasteInitialized; + private int _editorGeneration; - private List _templateNotes = []; private List _presets = []; private List _importedTokenNames = []; private IReadOnlyList _pages = []; + private IDialogReference? _previewDialog; + private IDisposable? _navigationRegistration; private int? _selectedPresetId; - private int? _selectedNoteId; - private string _saveAsName = string.Empty; - private string? _templateWarning; + private string _templateMarkdown = string.Empty; private PrintEntityType _entityType = PrintEntityType.Student; private int? _grade; private int? _tsaYear; @@ -301,41 +277,20 @@ else private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt; private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; - private static string FormatToken(string token) => "{{" + token + "}}"; + private string _snapshotMarkdown = string.Empty; + private PrintEntityType _snapshotEntityType = PrintEntityType.Student; + private string _snapshotFiltersJson = string.Empty; private string PrintPageStyle => $"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};"; - private IEnumerable<(string Label, IReadOnlyList Tokens)> TokenGroups - { - get - { - yield return ("Layout", PrintFieldCatalog.Layout); - yield return ("Chapter", PrintFieldCatalog.Chapter); - yield return (_entityType.ToString(), PrintFieldCatalog.EntityTokens(_entityType)); - if (_entityType == PrintEntityType.Student) - yield return ("Event ranks", PrintFieldCatalog.StudentRanks); - if (_entityType == PrintEntityType.Student && _importedTokenNames.Count > 0) - yield return ("Additional fields", _importedTokenNames); - } - } - - private bool CanPreview => - _selectedNoteId.HasValue - && SelectedTemplateNote is { Content: not null } note - && !string.IsNullOrWhiteSpace(note.Content) - && !note.IsDeleted - && _templateWarning is null; - - private bool CanSaveAs => - CanPreview && !string.IsNullOrWhiteSpace(_saveAsName); - - private Note? SelectedTemplateNote => - _templateNotes.FirstOrDefault(n => n.Id == _selectedNoteId); + private bool CanPreview => !string.IsNullOrWhiteSpace(_templateMarkdown); protected override void OnInitialized() { _cancellationTokenSource = new CancellationTokenSource(); + CaptureSnapshot(); + _navigationRegistration = NavigationManager.RegisterLocationChangingHandler(OnLocationChanging); } protected override async Task OnInitializedAsync() @@ -343,6 +298,19 @@ else await LoadAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pasteInitialized || _isDisposed) + return; + + await Task.Delay(150); + if (_isDisposed) + return; + + await MarkdownTablePasteService.InitializeAsync(EditorElementId); + _pasteInitialized = true; + } + private async Task LoadAsync() { if (_isDisposed) @@ -352,14 +320,6 @@ else try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; - - var notes = await NotesService.GetNotesAsync(); - _templateNotes = - [ - .. notes.Where(n => !NoteNamingService.IsPageNote(n.Title)) - .OrderBy(n => n.Title) - ]; - await RefreshImportedTokenNamesAsync(token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; } @@ -376,23 +336,40 @@ else } } - private void MarkStale() + private async Task MarkStaleAsync() { _previewStale = true; _pages = []; + await ClosePreviewDialogAsync(); } - private void OnEntityTypeChanged(PrintEntityType value) + private void MarkStale() => _ = MarkStaleAsync(); + + private async Task ClosePreviewDialogAsync() + { + var dialog = _previewDialog; + _previewDialog = null; + if (dialog is null) + return; + + try + { + dialog.Close(); + } + catch (InvalidOperationException) + { + } + } + + private async Task OnEntityTypeChanged(PrintEntityType value) { _entityType = value; - _templateWarning = null; - MarkStale(); + await MarkStaleAsync(); } - private void OnNoteSelected(int? value) + private void OnMarkdownChanged(string? value) { - _selectedNoteId = value; - _templateWarning = null; + _templateMarkdown = value ?? string.Empty; MarkStale(); } @@ -466,12 +443,66 @@ else ]; } + private async Task FlushEditorAsync() + { + var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId); + if (value is not null && value != _templateMarkdown) + { + _templateMarkdown = value; + await MarkStaleAsync(); + } + } + + private bool IsDirty() + { + return !string.Equals(_templateMarkdown, _snapshotMarkdown, StringComparison.Ordinal) + || _entityType != _snapshotEntityType + || !string.Equals(BuildFilters().ToJson(), _snapshotFiltersJson, StringComparison.Ordinal); + } + + private void CaptureSnapshot() + { + _snapshotMarkdown = _templateMarkdown; + _snapshotEntityType = _entityType; + _snapshotFiltersJson = BuildFilters().ToJson(); + } + + private async Task ConfirmDiscardIfDirtyAsync() + { + await FlushEditorAsync(); + if (!IsDirty()) + return true; + + var confirmed = await DialogService.ShowMessageBox( + "Unsaved changes", + "Discard unsaved template or filter changes?", + yesText: "Discard", + cancelText: "Stay"); + + return confirmed == true && !_isDisposed; + } + + private async ValueTask OnLocationChanging(LocationChangingContext context) + { + if (_isDisposed) + return; + + if (!await ConfirmDiscardIfDirtyAsync()) + context.PreventNavigation(); + } + private async Task OnPresetSelected(int? id) { + if (id == _selectedPresetId) + return; + + if (!await ConfirmDiscardIfDirtyAsync()) + return; + _selectedPresetId = id; if (!id.HasValue) { - MarkStale(); + await MarkStaleAsync(); return; } @@ -480,14 +511,38 @@ else return; ApplyPreset(preset); - MarkStale(); - await Task.CompletedTask; + CaptureSnapshot(); + await MarkStaleAsync(); + } + + private async Task NewTemplate() + { + if (_isDisposed || !await ConfirmDiscardIfDirtyAsync()) + return; + + _selectedPresetId = null; + _templateMarkdown = string.Empty; + _entityType = PrintEntityType.Student; + _grade = null; + _tsaYear = null; + _officerChoice = "any"; + _teamIdentifierContains = null; + _eventNameContains = null; + _eventFormat = null; + _regionalChoice = "any"; + _newPagePerRecord = true; + _fontSizePt = PrintPresetFilters.DefaultFontSizePt; + _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; + _editorGeneration++; + _pasteInitialized = false; + CaptureSnapshot(); + await MarkStaleAsync(); } private void ApplyPreset(PrintPreset preset) { - _saveAsName = preset.Name; _entityType = preset.EntityType; + _templateMarkdown = preset.TemplateMarkdown ?? string.Empty; var filters = PrintPresetFilters.FromJson(preset.FiltersJson); _grade = filters.Grade; _tsaYear = filters.TsaYear; @@ -499,18 +554,8 @@ else _newPagePerRecord = filters.NewPagePerRecord; _fontSizePt = filters.FontSizePt; _answerSpaceLines = filters.AnswerSpaceLines; - - var note = _templateNotes.FirstOrDefault(n => n.Id == preset.NoteId); - if (note is null || note.IsDeleted || string.IsNullOrWhiteSpace(note.Content)) - { - _selectedNoteId = null; - _templateWarning = "This preset's template note is missing or empty. Choose another note."; - } - else - { - _selectedNoteId = note.Id; - _templateWarning = null; - } + _editorGeneration++; + _pasteInitialized = false; } private PrintPresetFilters BuildFilters() => @@ -528,23 +573,84 @@ else AnswerSpaceLines = _answerSpaceLines }; + private async Task OpenTokenDialog() + { + if (_isDisposed) + return; + + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + await RefreshImportedTokenNamesAsync(token); + + var parameters = new DialogParameters + { + { x => x.EntityType, _entityType }, + { x => x.ImportedFieldNames, _importedTokenNames }, + { x => x.OnInsert, EventCallback.Factory.Create(this, InsertToken) } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + await DialogService.ShowAsync("Insert token", parameters, options); + } + + private async Task InsertToken(string tokenName) + { + if (_isDisposed) + return; + + await FlushEditorAsync(); + var wrapped = "{{" + tokenName + "}}"; + var inserted = await MarkdownTablePasteService.InsertAtCursorAsync(EditorElementId, wrapped); + if (!inserted) + { + _templateMarkdown += wrapped; + await MarkdownTablePasteService.SetValueAsync(EditorElementId, _templateMarkdown); + } + else + { + var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId); + if (value is not null) + _templateMarkdown = value; + } + + await MarkStaleAsync(); + } + private async Task Preview() { - if (_isDisposed || !CanPreview) + if (_isDisposed) return; + await FlushEditorAsync(); + if (!CanPreview) + { + if (!_isDisposed) + Snackbar.Add("Write a template before previewing.", Severity.Warning); + return; + } + + if (!_previewStale && _didPreview) + { + await OpenPreviewDialogAsync(); + return; + } + _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await RefreshImportedTokenNamesAsync(token); - var note = SelectedTemplateNote!; _pages = await NotePrintService.PreviewAsync( new NotePrintRequest { EntityType = _entityType, - TemplateMarkdown = note.Content ?? string.Empty, + TemplateMarkdown = _templateMarkdown, Filters = BuildFilters(), ImportedFieldCatalog = _importedTokenNames }, @@ -553,12 +659,8 @@ else _previewStale = false; _didPreview = true; - if (_pages.Count > 75 && !_isDisposed) - { - Snackbar.Add( - $"This preview has {_pages.Count} pages. Printing a large set can be slow.", - Severity.Warning); - } + if (!_isDisposed) + await OpenPreviewDialogAsync(); } catch (TaskCanceledException) { @@ -578,6 +680,46 @@ else } } + private async Task OpenPreviewDialogAsync() + { + await ClosePreviewDialogAsync(); + + var parameters = new DialogParameters + { + { x => x.Pages, _pages }, + { x => x.NewPagePerRecord, _newPagePerRecord }, + { x => x.PrintPageStyle, PrintPageStyle }, + { x => x.OnPrint, EventCallback.Factory.Create(this, Print) } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.ExtraLarge, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Print preview", parameters, options); + _previewDialog = dialog; + _ = TrackPreviewDialog(dialog); + } + + private async Task TrackPreviewDialog(IDialogReference dialog) + { + try + { + await dialog.Result; + } + catch (TaskCanceledException) + { + } + finally + { + if (_previewDialog == dialog) + _previewDialog = null; + } + } + private async Task Print() { if (_isDisposed || _previewStale || _pages.Count == 0) @@ -595,40 +737,33 @@ else } } - private async Task CopyToken(string token) + private async Task SavePreset() { if (_isDisposed) return; - try - { - await ClipboardService.WriteTextAsync($"{{{{{token}}}}}"); - if (!_isDisposed) - Snackbar.Add($"Copied {FormatToken(token)}", Severity.Info); - } - catch (JSDisconnectedException) - { - } - catch (TaskCanceledException) - { - } - catch (Exception ex) + await FlushEditorAsync(); + if (!CanPreview) { if (!_isDisposed) - Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error); + Snackbar.Add("Write a template before saving.", Severity.Warning); + return; } - } - private async Task SaveAsPreset() - { - if (_isDisposed || !CanSaveAs || !_selectedNoteId.HasValue) + if (_selectedPresetId.HasValue) + { + await UpdateSelectedPresetAsync(); + return; + } + + var name = await PromptForPresetNameAsync(); + if (string.IsNullOrWhiteSpace(name) || _isDisposed) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; - var name = _saveAsName.Trim(); if (await PrintPresetService.NameExistsAsync(name, null, token)) { Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning); @@ -639,7 +774,7 @@ else new PrintPreset { Name = name, - NoteId = _selectedNoteId.Value, + TemplateMarkdown = _templateMarkdown, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, @@ -647,6 +782,7 @@ else _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = created.Id; + CaptureSnapshot(); if (!_isDisposed) Snackbar.Add($"Saved print preset '{name}'.", Severity.Success); } @@ -668,9 +804,26 @@ else } } - private async Task UpdatePreset() + private async Task PromptForPresetNameAsync() { - if (_isDisposed || !_selectedPresetId.HasValue || !_selectedNoteId.HasValue || !CanPreview) + var options = new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Save print preset", options); + var result = await dialog.Result; + if (result is null || result.Canceled || result.Data is not string name) + return null; + + return name.Trim(); + } + + private async Task UpdateSelectedPresetAsync() + { + if (_isDisposed || !_selectedPresetId.HasValue) return; _isBusy = true; @@ -686,15 +839,16 @@ else { Id = existing.Id, Name = existing.Name, - NoteId = _selectedNoteId.Value, + TemplateMarkdown = _templateMarkdown, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; + CaptureSnapshot(); if (!_isDisposed) - Snackbar.Add($"Updated print preset '{existing.Name}'.", Severity.Success); + Snackbar.Add($"Saved print preset '{existing.Name}'.", Severity.Success); } catch (TaskCanceledException) { @@ -705,7 +859,7 @@ else catch (Exception ex) { if (!_isDisposed) - Snackbar.Add($"Could not update: {ex.Message}", Severity.Error); + Snackbar.Add($"Could not save: {ex.Message}", Severity.Error); } finally { @@ -739,6 +893,7 @@ else await PrintPresetService.DeleteAsync(existing.Id, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = null; + CaptureSnapshot(); if (!_isDisposed) Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info); } @@ -765,6 +920,9 @@ else if (!_isDisposed) { _isDisposed = true; + _navigationRegistration?.Dispose(); + _navigationRegistration = null; + await ClosePreviewDialogAsync(); _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; diff --git a/WebApp/Components/Features/Print/PrintPageStack.razor b/WebApp/Components/Features/Print/PrintPageStack.razor new file mode 100644 index 0000000..b448f73 --- /dev/null +++ b/WebApp/Components/Features/Print/PrintPageStack.razor @@ -0,0 +1,26 @@ +@using WebApp.Services + +@for (var i = 0; i < Pages.Count; i++) +{ + var page = Pages[i]; + var isLast = i == Pages.Count - 1; + var pageClass = NewPagePerRecord && !isLast + ? "note-print-page pagebreak" + : "note-print-page"; + +
+ @((MarkupString)page.Html) +
+
+} + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Pages { get; set; } = []; + + [Parameter] + public bool NewPagePerRecord { get; set; } = true; + + [Parameter] + public string PrintPageStyle { get; set; } = string.Empty; +} diff --git a/WebApp/Components/Features/Print/PrintPresetNameDialog.razor b/WebApp/Components/Features/Print/PrintPresetNameDialog.razor new file mode 100644 index 0000000..e3a194c --- /dev/null +++ b/WebApp/Components/Features/Print/PrintPresetNameDialog.razor @@ -0,0 +1,37 @@ + + + + + + Cancel + + Save + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + private string _name = string.Empty; + + private void Save() + { + var name = _name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + MudDialog.Close(DialogResult.Ok(name)); + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/WebApp/Components/Features/Print/PrintPreviewDialog.razor b/WebApp/Components/Features/Print/PrintPreviewDialog.razor new file mode 100644 index 0000000..32c0954 --- /dev/null +++ b/WebApp/Components/Features/Print/PrintPreviewDialog.razor @@ -0,0 +1,60 @@ +@using WebApp.Services + + + + @if (Pages.Count == 0) + { + No matching records for these filters. + } + else + { + @if (Pages.Count > 75) + { + + This preview has @Pages.Count pages. Printing a large set can be slow. + + } + + @Pages.Count page@(Pages.Count == 1 ? "" : "s") + + + } + + + Close + + Print + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter, EditorRequired] + public IReadOnlyList Pages { get; set; } = []; + + [Parameter] + public bool NewPagePerRecord { get; set; } = true; + + [Parameter] + public string PrintPageStyle { get; set; } = string.Empty; + + [Parameter] + public EventCallback OnPrint { get; set; } + + private async Task PrintAsync() + { + if (OnPrint.HasDelegate) + await OnPrint.InvokeAsync(); + } + + private void Close() => MudDialog.Close(); +} diff --git a/WebApp/Components/Features/Print/PrintTokenInsertDialog.razor b/WebApp/Components/Features/Print/PrintTokenInsertDialog.razor new file mode 100644 index 0000000..67e6a57 --- /dev/null +++ b/WebApp/Components/Features/Print/PrintTokenInsertDialog.razor @@ -0,0 +1,105 @@ +@using Core.Printing +@inject ClipboardService ClipboardService +@inject ISnackbar Snackbar + + + + + + @if (!HasAnyMatches) + { + No tokens match this search. + } + else + { + @foreach (var group in TokenGroups) + { + var tokens = Visible(group.Tokens); + if (tokens.Count == 0) + continue; + @group.Label +
+ +
+ } + } +
+ + Close + +
+ +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public PrintEntityType EntityType { get; set; } + + [Parameter] + public IReadOnlyList ImportedFieldNames { get; set; } = []; + + [Parameter] + public EventCallback OnInsert { get; set; } + + private string _search = string.Empty; + + private IEnumerable<(string Label, IReadOnlyList Tokens)> TokenGroups + { + get + { + yield return ("Layout", PrintFieldCatalog.Layout); + yield return ("Chapter", PrintFieldCatalog.Chapter); + yield return (EntityType.ToString(), PrintFieldCatalog.EntityTokens(EntityType)); + if (EntityType == PrintEntityType.Student) + yield return ("Event ranks", PrintFieldCatalog.StudentRanks); + if (EntityType == PrintEntityType.Student && ImportedFieldNames.Count > 0) + yield return ("Additional fields", ImportedFieldNames); + } + } + + private bool HasAnyMatches => + TokenGroups.Any(group => Visible(group.Tokens).Count > 0); + + private IReadOnlyList Visible(IReadOnlyList tokens) => + string.IsNullOrWhiteSpace(_search) + ? tokens + : [.. tokens.Where(Matches)]; + + private bool Matches(string token) => + string.IsNullOrWhiteSpace(_search) + || token.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase); + + private async Task Insert(string token) + { + if (OnInsert.HasDelegate) + await OnInsert.InvokeAsync(token); + } + + private async Task Copy(string token) + { + try + { + await ClipboardService.WriteTextAsync("{{" + token + "}}"); + Snackbar.Add("Copied {{" + token + "}}", Severity.Info); + } + catch (JSDisconnectedException) + { + } + catch (TaskCanceledException) + { + } + catch (Exception ex) + { + Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); +} diff --git a/WebApp/Components/Features/Print/TokenChips.razor b/WebApp/Components/Features/Print/TokenChips.razor new file mode 100644 index 0000000..c27acc4 --- /dev/null +++ b/WebApp/Components/Features/Print/TokenChips.razor @@ -0,0 +1,27 @@ +@foreach (var token in Tokens) +{ + var name = token; + + + {{@name}} + + + + + +} + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Tokens { get; set; } = []; + + [Parameter] + public EventCallback Insert { get; set; } + + [Parameter] + public EventCallback Copy { get; set; } +} diff --git a/WebApp/Services/MarkdownTablePasteService.cs b/WebApp/Services/MarkdownTablePasteService.cs index c7c918d..a65ecb7 100644 --- a/WebApp/Services/MarkdownTablePasteService.cs +++ b/WebApp/Services/MarkdownTablePasteService.cs @@ -38,4 +38,55 @@ public class MarkdownTablePasteService _logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown"); } } + + public async Task GetValueAsync(string editorId) + { + try + { + return await _jsRuntime.InvokeAsync("markdownTablePaste.getValue", editorId); + } + catch (JSDisconnectedException) + { + return null; + } + catch (JSException ex) + { + _logger.LogWarning(ex, "Failed to read markdown editor {EditorId}", editorId); + return null; + } + } + + public async Task SetValueAsync(string editorId, string text) + { + try + { + return await _jsRuntime.InvokeAsync("markdownTablePaste.setValue", editorId, text); + } + catch (JSDisconnectedException) + { + return false; + } + catch (JSException ex) + { + _logger.LogWarning(ex, "Failed to set markdown editor {EditorId}", editorId); + return false; + } + } + + public async Task InsertAtCursorAsync(string editorId, string text) + { + try + { + return await _jsRuntime.InvokeAsync("markdownTablePaste.insertAtCursor", editorId, text); + } + catch (JSDisconnectedException) + { + return false; + } + catch (JSException ex) + { + _logger.LogWarning(ex, "Failed to insert into markdown editor {EditorId}", editorId); + return false; + } + } } diff --git a/WebApp/Services/PrintPresetService.cs b/WebApp/Services/PrintPresetService.cs index d538290..fdfec36 100644 --- a/WebApp/Services/PrintPresetService.cs +++ b/WebApp/Services/PrintPresetService.cs @@ -19,7 +19,6 @@ public class PrintPresetService : IPrintPresetService { return await _context.PrintPresets .AsNoTracking() - .Include(p => p.Note) .OrderBy(p => p.Name) .ToListAsync(cancellationToken); } @@ -28,7 +27,6 @@ public class PrintPresetService : IPrintPresetService { return await _context.PrintPresets .AsNoTracking() - .Include(p => p.Note) .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); } @@ -48,7 +46,7 @@ public class PrintPresetService : IPrintPresetService { preset.Name = preset.Name.Trim(); preset.UpdatedAt = DateTime.UtcNow; - preset.Note = null!; + preset.TemplateMarkdown ??= string.Empty; if (await NameExistsAsync(preset.Name, null, cancellationToken)) throw new InvalidOperationException($"A print preset named '{preset.Name}' already exists."); @@ -72,7 +70,7 @@ public class PrintPresetService : IPrintPresetService throw new InvalidOperationException($"A print preset named '{name}' already exists."); existing.Name = name; - existing.NoteId = preset.NoteId; + existing.TemplateMarkdown = preset.TemplateMarkdown ?? string.Empty; existing.EntityType = preset.EntityType; existing.FiltersJson = preset.FiltersJson; existing.UpdatedAt = DateTime.UtcNow; diff --git a/WebApp/wwwroot/app.css b/WebApp/wwwroot/app.css index 426a23d..b1d1a60 100644 --- a/WebApp/wwwroot/app.css +++ b/WebApp/wwwroot/app.css @@ -16,6 +16,16 @@ display: none !important; } + .print-only { + display: block !important; + } + + .mud-overlay, + .mud-dialog-container, + .mud-overlay-dialog { + display: none !important; + } + .nobrk { break-inside: avoid; } @@ -375,6 +385,14 @@ height: auto; } +.print-only { + display: none; +} + +#page-printer-editor .EasyMDEContainer .CodeMirror { + min-height: 16rem; +} + .note-print-page { --print-font-size: 12pt; --print-answer-lines: 3; diff --git a/WebApp/wwwroot/js/markdownTablePaste.js b/WebApp/wwwroot/js/markdownTablePaste.js index 5313626..0d54e8e 100644 --- a/WebApp/wwwroot/js/markdownTablePaste.js +++ b/WebApp/wwwroot/js/markdownTablePaste.js @@ -2,6 +2,101 @@ // Handles both HTML tables (from Google Sheets) and tab-separated values window.markdownTablePaste = { + findEditor: function(editorId) { + let textarea = null; + let codeMirror = null; + + if (editorId) { + const editorElement = document.getElementById(editorId); + if (editorElement) { + textarea = editorElement.querySelector('textarea'); + const cmEl = editorElement.querySelector('.CodeMirror'); + if (cmEl && cmEl.CodeMirror) { + codeMirror = cmEl.CodeMirror; + } + } + } else { + const containers = document.querySelectorAll('.EasyMDEContainer'); + if (containers.length > 0) { + const lastContainer = containers[containers.length - 1]; + textarea = lastContainer.querySelector('textarea'); + const cmEl = lastContainer.querySelector('.CodeMirror'); + if (cmEl && cmEl.CodeMirror) { + codeMirror = cmEl.CodeMirror; + } + } + } + + if (!codeMirror && textarea && window.EasyMDE) { + const easyMDEInstances = window.EasyMDE.instances || []; + for (let i = 0; i < easyMDEInstances.length; i++) { + const instance = easyMDEInstances[i]; + if (instance && instance.codemirror) { + const cmTextarea = instance.codemirror.getTextArea(); + if (cmTextarea === textarea) { + codeMirror = instance.codemirror; + break; + } + } + } + + if (!codeMirror && textarea.parentElement) { + const parent = textarea.parentElement; + if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) { + codeMirror = parent._easyMDEInstance.codemirror; + } + } + } + + return { textarea, codeMirror }; + }, + + getValue: function(editorId) { + const found = this.findEditor(editorId); + if (found.codeMirror) { + return found.codeMirror.getValue(); + } + if (found.textarea) { + return found.textarea.value; + } + return null; + }, + + setValue: function(editorId, text) { + const found = this.findEditor(editorId); + if (found.codeMirror) { + found.codeMirror.setValue(text ?? ''); + return true; + } + if (found.textarea) { + found.textarea.value = text ?? ''; + found.textarea.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + return false; + }, + + insertAtCursor: function(editorId, text) { + const found = this.findEditor(editorId); + if (found.codeMirror) { + found.codeMirror.replaceSelection(text); + found.codeMirror.focus(); + return true; + } + if (found.textarea) { + const start = found.textarea.selectionStart || 0; + const end = found.textarea.selectionEnd || 0; + const before = found.textarea.value.substring(0, start); + const after = found.textarea.value.substring(end); + found.textarea.value = before + text + after; + found.textarea.selectionStart = found.textarea.selectionEnd = before.length + text.length; + found.textarea.dispatchEvent(new Event('input', { bubbles: true })); + found.textarea.focus(); + return true; + } + return false; + }, + /** * Initializes paste handler for a MarkdownEditor instance. * Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler. @@ -13,78 +108,11 @@ window.markdownTablePaste = { const tryInitialize = function() { attempts++; - - let textarea = null; - let codeMirror = null; - - // Try to find the textarea element - if (editorId) { - const editorElement = document.getElementById(editorId); - if (editorElement) { - textarea = editorElement.querySelector('textarea'); - } - } else { - // Find the most recently created EasyMDE textarea (last one in DOM) - const containers = document.querySelectorAll('.EasyMDEContainer'); - if (containers.length > 0) { - const lastContainer = containers[containers.length - 1]; - textarea = lastContainer.querySelector('textarea'); - } - - // Fallback: try to find any textarea near an editor-toolbar - if (!textarea) { - const toolbars = document.querySelectorAll('.editor-toolbar'); - if (toolbars.length > 0) { - const lastToolbar = toolbars[toolbars.length - 1]; - const nextSibling = lastToolbar.nextElementSibling; - if (nextSibling && nextSibling.tagName === 'TEXTAREA') { - textarea = nextSibling; - } - } - } - - // Another fallback: find any textarea that's a child of a container with editor classes - if (!textarea) { - const allTextareas = document.querySelectorAll('textarea'); - for (let ta of allTextareas) { - const parent = ta.parentElement; - if (parent && ( - parent.classList.contains('EasyMDEContainer') || - parent.classList.contains('editor') || - parent.querySelector('.editor-toolbar') - )) { - textarea = ta; - break; - } - } - } - } - - // If we found a textarea, try to get the CodeMirror instance from EasyMDE - if (textarea) { - if (window.EasyMDE) { - const easyMDEInstances = window.EasyMDE.instances || []; - - for (let i = 0; i < easyMDEInstances.length; i++) { - const instance = easyMDEInstances[i]; - if (instance && instance.codemirror) { - const cmTextarea = instance.codemirror.getTextArea(); - if (cmTextarea === textarea) { - codeMirror = instance.codemirror; - break; - } - } - } - - // Alternative: try to get CodeMirror from the textarea's parent - if (!codeMirror && textarea.parentElement) { - const parent = textarea.parentElement; - if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) { - codeMirror = parent._easyMDEInstance.codemirror; - } - } - } + const found = window.markdownTablePaste.findEditor(editorId); + const textarea = found.textarea; + const codeMirror = found.codeMirror; + if (textarea || codeMirror) { const target = codeMirror || textarea; if (target) { diff --git a/docs/instructions/page-printer.md b/docs/instructions/page-printer.md index a496488..4fb5451 100644 --- a/docs/instructions/page-printer.md +++ b/docs/instructions/page-printer.md @@ -1,16 +1,16 @@ # Page printer **Created:** 2026-08-29 -**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. +**Last updated:** 2026-09-03 +**Description:** Merge a markdown template onto students, teams, or events and print one page per match. Save the recipe as a print preset. ## Where to open it Sign in and go to **Tools → Page printer** (`/print`). -## Write a template note +## Write a template -Create a standalone note on **Notes** (not a page note or student note). Use `{{tokens}}` for values. Click a token chip on the printer page to copy it. +Edit the markdown on the printer page. Use `{{tokens}}` for values. **Insert token** opens a searchable list; click a token to insert it at the caret, or use the copy icon to put `{{Name}}` on the clipboard. ```markdown # Interview — {{FirstName}} {{LastName}} @@ -64,6 +64,8 @@ Put `{{AnswerSpace}}` where you want ruled write-in lines. Markdown collapses bl Additional-field tokens use the same names as the Students index columns (for example `{{Interview Time}}`). Those values come from each student's `#Student:{id}` `## Additional fields` table. See [student-notes-import.md](student-notes-import.md). +Templates used to live on **Notes**. After this change they are stored on the print preset. Old template notes can be deleted from Notes once `/print` looks right. + ## Filters Pick **Students**, **Teams**, or **Events**, then optional filters. @@ -72,18 +74,18 @@ For students: Grade, TSA year, and officer (any / officers only / non-officers). ## Print presets -A print preset stores the *recipe* only: name, template note, entity type, and filters. It does not store merged pages. Each Preview uses the current roster and notes. +A print preset stores the *recipe* only: name, markdown, entity type, and filters. It does not store merged pages. Each Preview uses the current roster and notes. -1. Set the note, entity, and filters. -2. Enter a name and click **Save as**. +1. Write the template, set the entity and filters. +2. Click **Save**. If no preset is selected, enter a name in the dialog. 3. Later, choose a preset, click **Preview**, then **Print**. -4. **Update** overwrites the selected preset. **Delete** removes it. +4. **Save** overwrites the selected preset. **Delete** removes the preset, not the editor text. **New** starts a blank template. -If the template note was removed, the filters still load. Choose another note before Preview. +Switching presets, clicking **New**, or leaving the page asks before discarding unsaved template or filter changes. To keep a copy under a new name, clear the preset (or click **New** after copying the markdown), then **Save**. ## Print -**Preview** builds the pages. **Print** opens the browser print dialog (same as other handouts). Navigation is hidden. +**Preview** builds the pages and opens a lightbox. **Print** (in the lightbox or on the page, while the preview is still current) opens the browser print dialog. Navigation and the editor are hidden. **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. @@ -91,4 +93,4 @@ If that option is off and the template has **exactly one** markdown table, the h **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 +If Preview finds more than 75 matches, a warning is shown in the preview lightbox. diff --git a/docs/plans/page-printer.md b/docs/plans/page-printer.md index 2a73016..55925da 100644 --- a/docs/plans/page-printer.md +++ b/docs/plans/page-printer.md @@ -1,17 +1,18 @@ # Page printer plan **Created:** 2026-08-29 -**Last updated:** 2026-08-30 -**Description:** Implementation notes for the Tools page printer (markdown note merge + print presets). +**Last updated:** 2026-09-03 +**Description:** Implementation notes for the Tools page printer (markdown merge + print presets). User-facing steps: [docs/instructions/page-printer.md](../instructions/page-printer.md). ## Built - Core merge: `{{tokens}}`, `{{PageBreak}}`, `{{AnswerSpace}}`, print presets JSON -- `/print` UI: entity filters, font size, answer-space lines, Preview/Print, save/load/update/delete presets +- `/print` UI: markdown editor on the printer page, token-insert dialog, preview lightbox, entity filters, font size, answer-space lines, Preview/Print, one Save (name dialog when no preset is selected) +- Templates stored on `PrintPreset.TemplateMarkdown` (not Notes). Migration copies old note content, then drops `NoteId`. - Student pages sort by last name, then first name - Student event-rank tokens: `{{Rank1}}`–`{{Rank10}}`, `.ShortName` / `.Attributes`, and `{{RankedEvents}}` badges - Event `{{RankedStudents}}` badges (students who ranked the event) and `{{EventAttributes}}` - `{{Legend}}` from the shared attribute-mark catalog (`EventAttributeMarks`) -- EF migration `AddPrintPresets` (applied on next app start) +- EF migrations `AddPrintPresets` and `PrintPresetTemplateMarkdown` (applied on next app start)