feat: edit print templates on the printer page and store them on presets

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 22:45:56 -04:00
co-authored by Cursor
parent 1337d9833d
commit c03ffc0833
18 changed files with 904 additions and 293 deletions
+2 -4
View File
@@ -4,7 +4,7 @@ using Core.Printing;
namespace Core.Entities; namespace Core.Entities;
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public class PrintPreset public class PrintPreset
{ {
@@ -14,9 +14,7 @@ public class PrintPreset
[StringLength(100)] [StringLength(100)]
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public int NoteId { get; set; } public string TemplateMarkdown { get; set; } = string.Empty;
public Note Note { get; set; } = null!;
public PrintEntityType EntityType { get; set; } public PrintEntityType EntityType { get; set; }
+17
View File
@@ -43,6 +43,21 @@ public static class PrintFieldCatalog
/// </summary> /// </summary>
public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames]; 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<string> 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 = public static readonly string[] Team =
[ [
"Identifier", "Identifier",
@@ -52,6 +67,7 @@ public static class PrintFieldCatalog
"EventFormat", "EventFormat",
"TeamSize", "TeamSize",
"NationalEligibility", "NationalEligibility",
"Eligibility",
"RegionalTeamCount", "RegionalTeamCount",
"StateTeamCount", "StateTeamCount",
"Description", "Description",
@@ -66,6 +82,7 @@ public static class PrintFieldCatalog
"EventFormat", "EventFormat",
"TeamSize", "TeamSize",
"NationalEligibility", "NationalEligibility",
"Eligibility",
"RegionalTeamCount", "RegionalTeamCount",
"StateTeamCount", "StateTeamCount",
"LevelOfEffort", "LevelOfEffort",
@@ -26,10 +26,8 @@ public class PrintPresetConfiguration : IEntityTypeConfiguration<PrintPreset>
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
builder.HasOne(p => p.Note) builder.Property(p => p.TemplateMarkdown)
.WithMany() .IsRequired()
.HasForeignKey(p => p.NoteId) .HasColumnType("TEXT");
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
} }
} }
@@ -0,0 +1,89 @@
using System;
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260903010000_PrintPresetTemplateMarkdown")]
public partial class PrintPresetTemplateMarkdown : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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");""");
}
/// <inheritdoc />
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");""");
}
}
}
+3 -15
View File
@@ -197,8 +197,9 @@ namespace Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("NoteId") b.Property<string>("TemplateMarkdown")
.HasColumnType("INTEGER"); .IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -208,8 +209,6 @@ namespace Data.Migrations
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.HasIndex("NoteId");
b.ToTable("PrintPresets"); b.ToTable("PrintPresets");
}); });
@@ -503,17 +502,6 @@ namespace Data.Migrations
b.Navigation("Note"); 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 => modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{ {
b.HasOne("Core.Entities.EventDefinition", "EventDefinition") b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
+10
View File
@@ -31,6 +31,14 @@ public class PrintFieldCatalog_Tests
Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3)); Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1")); Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), 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] [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("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes")); 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("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("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("StateTeamCount")); 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("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("NationalEligibility")); 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("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("StateTeamCount")); 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(NoteTemplateMerger.RankedStudentsToken));
+337 -179
View File
@@ -2,20 +2,19 @@
@attribute [Authorize] @attribute [Authorize]
@implements IAsyncDisposable @implements IAsyncDisposable
@using Core.Printing @using Core.Printing
@using Core.Services
@inject INotesService NotesService @inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject INotePrintService NotePrintService @inject INotePrintService NotePrintService
@inject IPrintPresetService PrintPresetService @inject IPrintPresetService PrintPresetService
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject ClipboardService ClipboardService @inject NavigationManager NavigationManager
@inject MarkdownTablePasteService MarkdownTablePasteService
<div class="no-print"> <div class="no-print">
<PageHeader Title="Page printer" <PageHeader Title="Page printer"
Description="Merge a markdown note onto students, teams, or events and print one page per match." Description="Write a markdown template, merge it onto students, teams, or events, and print."
Icon="@Icons.Material.Filled.Print" /> Icon="@Icons.Material.Filled.Print" />
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mb-4"> <MudPaper Elevation="2" Class="pa-3 pa-md-6 mb-4">
@@ -25,7 +24,7 @@
} }
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="5">
<MudSelect T="int?" <MudSelect T="int?"
Label="Print preset" Label="Print preset"
Value="_selectedPresetId" Value="_selectedPresetId"
@@ -38,23 +37,17 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="7" Class="d-flex align-center gap-2 flex-wrap">
<MudTextField @bind-Value="_saveAsName"
Label="Save as name"
Variant="Variant.Outlined"
Immediate="true" />
</MudItem>
<MudItem xs="12" md="5" Class="d-flex align-center gap-2 flex-wrap">
<MudButton Variant="Variant.Outlined" <MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Save" OnClick="NewTemplate"
OnClick="SaveAsPreset" Disabled="@_isBusy">
Disabled="@(_isBusy || !CanSaveAs)"> New
Save as
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" <MudButton Variant="Variant.Outlined"
OnClick="UpdatePreset" StartIcon="@Icons.Material.Filled.Save"
Disabled="@(_isBusy || !_selectedPresetId.HasValue || !CanPreview)"> OnClick="SavePreset"
Update Disabled="@_isBusy">
Save
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" <MudButton Variant="Variant.Outlined"
Color="Color.Error" Color="Color.Error"
@@ -75,38 +68,10 @@
<MudSelectItem Value="PrintEntityType.Event">Events</MudSelectItem> <MudSelectItem Value="PrintEntityType.Event">Events</MudSelectItem>
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="8">
@if (_templateNotes.Count == 0)
{
<MudAlert Severity="Severity.Info">
Create a standalone note on <MudLink Href="/notes">Notes</MudLink> to use as a template.
</MudAlert>
}
else
{
<MudSelect T="int?"
Label="Template note"
Value="_selectedNoteId"
ValueChanged="OnNoteSelected"
Variant="Variant.Outlined">
@foreach (var note in _templateNotes)
{
<MudSelectItem T="int?" Value="@note.Id">@note.Title</MudSelectItem>
}
</MudSelect>
}
</MudItem>
@if (_templateWarning is not null)
{
<MudItem xs="12">
<MudAlert Severity="Severity.Warning">@_templateWarning</MudAlert>
</MudItem>
}
@if (_entityType == PrintEntityType.Student) @if (_entityType == PrintEntityType.Student)
{ {
<MudItem xs="12" sm="4" md="3"> <MudItem xs="12" sm="4" md="2">
<MudNumericField T="int?" <MudNumericField T="int?"
Label="Grade" Label="Grade"
Value="_grade" Value="_grade"
@@ -116,7 +81,7 @@
Max="12" Max="12"
Clearable="true" /> Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12" sm="4" md="3"> <MudItem xs="12" sm="4" md="2">
<MudNumericField T="int?" <MudNumericField T="int?"
Label="TSA year" Label="TSA year"
Value="_tsaYear" Value="_tsaYear"
@@ -126,7 +91,7 @@
Max="12" Max="12"
Clearable="true" /> Clearable="true" />
</MudItem> </MudItem>
<MudItem xs="12" sm="4" md="3"> <MudItem xs="12" sm="4" md="4">
<MudSelect T="string" <MudSelect T="string"
Label="Officer" Label="Officer"
Value="@_officerChoice" Value="@_officerChoice"
@@ -140,7 +105,7 @@
} }
else if (_entityType == PrintEntityType.Team) else if (_entityType == PrintEntityType.Team)
{ {
<MudItem xs="12" md="4"> <MudItem xs="12" md="8">
<MudTextField T="string" <MudTextField T="string"
Value="@_teamIdentifierContains" Value="@_teamIdentifierContains"
ValueChanged="OnTeamIdentifierChanged" ValueChanged="OnTeamIdentifierChanged"
@@ -186,12 +151,18 @@
} }
<MudItem xs="12"> <MudItem xs="12">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="flex-wrap"> <MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="flex-wrap mb-2">
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.DataObject"
OnClick="OpenTokenDialog"
Disabled="@_isBusy">
Insert token
</MudButton>
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Visibility" StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview" OnClick="Preview"
Disabled="@(_isBusy || !CanPreview)"> Disabled="@_isBusy">
Preview Preview
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" <MudButton Variant="Variant.Outlined"
@@ -223,6 +194,10 @@
Min="PrintPresetFilters.MinAnswerSpaceLines" Min="PrintPresetFilters.MinAnswerSpaceLines"
Max="PrintPresetFilters.MaxAnswerSpaceLines" Max="PrintPresetFilters.MaxAnswerSpaceLines"
Style="max-width: 8rem;" /> Style="max-width: 8rem;" />
@if (IsDirty())
{
<MudText Typo="Typo.caption" Color="Color.Warning">Unsaved changes</MudText>
}
@if (!_previewStale && _pages.Count > 0) @if (!_previewStale && _pages.Count > 0)
{ {
<MudText Typo="Typo.body2">@_pages.Count page@(_pages.Count == 1 ? "" : "s")</MudText> <MudText Typo="Typo.body2">@_pages.Count page@(_pages.Count == 1 ? "" : "s")</MudText>
@@ -235,60 +210,61 @@
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-1">Tokens (click to copy)</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">Template (Markdown)</MudText>
@foreach (var group in TokenGroups) <div id="@EditorElementId" @key="_editorGeneration">
<MarkdownEditor Value="@_templateMarkdown"
ValueChanged="OnMarkdownChanged"
Placeholder="Write the printable page. Insert tokens for student, team, or event values."
AutoSaveEnabled="false"
NativeSpellChecker="false"
HideIcons="@HiddenEditorIcons" />
</div>
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">Template preview</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown only — tokens are not merged here.</MudText>
@if (string.IsNullOrWhiteSpace(_templateMarkdown))
{ {
<MudText Typo="Typo.caption" Class="mud-text-secondary">@group.Label</MudText> <MudText Typo="Typo.body2" Class="mud-text-secondary">The template will preview here as you type.</MudText>
<div class="mb-2"> }
@foreach (var token in group.Tokens) else
{ {
<MudChip T="string" Size="Size.Small" OnClick="() => CopyToken(token)" Class="ma-1">@FormatToken(token)</MudChip> <MudPaper Elevation="0" Class="pa-3 note-print-page" Style="@($"background-color: var(--mud-palette-background-grey);{PrintPageStyle}")">
} <div class="markdown-content">
</div> @((MarkupString)MarkdownHelper.ToHtml(_templateMarkdown))
</div>
</MudPaper>
} }
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
</div> </div>
@if (_didPreview && _pages.Count == 0 && !_previewStale) <div class="print-only">
{ <PrintPageStack Pages="_pages"
<MudText Class="no-print mud-text-secondary">No matching records for these filters.</MudText> NewPagePerRecord="_newPagePerRecord"
} PrintPageStyle="@PrintPageStyle" />
else </div>
{
@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";
<MudContainer Class="@pageClass" Style="@PrintPageStyle">
<div class="markdown-content">
@((MarkupString)page.Html)
</div>
</MudContainer>
}
}
@code { @code {
private const string EditorElementId = "page-printer-editor";
private static readonly string[] HiddenEditorIcons = ["preview", "side-by-side", "fullscreen"];
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed; private bool _isDisposed;
private bool _isLoading = true; private bool _isLoading = true;
private bool _isBusy; private bool _isBusy;
private bool _previewStale = true; private bool _previewStale = true;
private bool _didPreview; private bool _didPreview;
private bool _pasteInitialized;
private int _editorGeneration;
private List<Note> _templateNotes = [];
private List<PrintPreset> _presets = []; private List<PrintPreset> _presets = [];
private List<string> _importedTokenNames = []; private List<string> _importedTokenNames = [];
private IReadOnlyList<NotePrintPage> _pages = []; private IReadOnlyList<NotePrintPage> _pages = [];
private IDialogReference? _previewDialog;
private IDisposable? _navigationRegistration;
private int? _selectedPresetId; private int? _selectedPresetId;
private int? _selectedNoteId; private string _templateMarkdown = string.Empty;
private string _saveAsName = string.Empty;
private string? _templateWarning;
private PrintEntityType _entityType = PrintEntityType.Student; private PrintEntityType _entityType = PrintEntityType.Student;
private int? _grade; private int? _grade;
private int? _tsaYear; private int? _tsaYear;
@@ -301,41 +277,20 @@ else
private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt; private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt;
private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; 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 => private string PrintPageStyle =>
$"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};"; $"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};";
private IEnumerable<(string Label, IReadOnlyList<string> Tokens)> TokenGroups private bool CanPreview => !string.IsNullOrWhiteSpace(_templateMarkdown);
{
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);
protected override void OnInitialized() protected override void OnInitialized()
{ {
_cancellationTokenSource = new CancellationTokenSource(); _cancellationTokenSource = new CancellationTokenSource();
CaptureSnapshot();
_navigationRegistration = NavigationManager.RegisterLocationChangingHandler(OnLocationChanging);
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -343,6 +298,19 @@ else
await LoadAsync(); 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() private async Task LoadAsync()
{ {
if (_isDisposed) if (_isDisposed)
@@ -352,14 +320,6 @@ else
try try
{ {
var token = _cancellationTokenSource?.Token ?? CancellationToken.None; 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); await RefreshImportedTokenNamesAsync(token);
_presets = [.. await PrintPresetService.GetAllAsync(token)]; _presets = [.. await PrintPresetService.GetAllAsync(token)];
} }
@@ -376,23 +336,40 @@ else
} }
} }
private void MarkStale() private async Task MarkStaleAsync()
{ {
_previewStale = true; _previewStale = true;
_pages = []; _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; _entityType = value;
_templateWarning = null; await MarkStaleAsync();
MarkStale();
} }
private void OnNoteSelected(int? value) private void OnMarkdownChanged(string? value)
{ {
_selectedNoteId = value; _templateMarkdown = value ?? string.Empty;
_templateWarning = null;
MarkStale(); 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<bool> 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) private async Task OnPresetSelected(int? id)
{ {
if (id == _selectedPresetId)
return;
if (!await ConfirmDiscardIfDirtyAsync())
return;
_selectedPresetId = id; _selectedPresetId = id;
if (!id.HasValue) if (!id.HasValue)
{ {
MarkStale(); await MarkStaleAsync();
return; return;
} }
@@ -480,14 +511,38 @@ else
return; return;
ApplyPreset(preset); ApplyPreset(preset);
MarkStale(); CaptureSnapshot();
await Task.CompletedTask; 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) private void ApplyPreset(PrintPreset preset)
{ {
_saveAsName = preset.Name;
_entityType = preset.EntityType; _entityType = preset.EntityType;
_templateMarkdown = preset.TemplateMarkdown ?? string.Empty;
var filters = PrintPresetFilters.FromJson(preset.FiltersJson); var filters = PrintPresetFilters.FromJson(preset.FiltersJson);
_grade = filters.Grade; _grade = filters.Grade;
_tsaYear = filters.TsaYear; _tsaYear = filters.TsaYear;
@@ -499,18 +554,8 @@ else
_newPagePerRecord = filters.NewPagePerRecord; _newPagePerRecord = filters.NewPagePerRecord;
_fontSizePt = filters.FontSizePt; _fontSizePt = filters.FontSizePt;
_answerSpaceLines = filters.AnswerSpaceLines; _answerSpaceLines = filters.AnswerSpaceLines;
_editorGeneration++;
var note = _templateNotes.FirstOrDefault(n => n.Id == preset.NoteId); _pasteInitialized = false;
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;
}
} }
private PrintPresetFilters BuildFilters() => private PrintPresetFilters BuildFilters() =>
@@ -528,23 +573,84 @@ else
AnswerSpaceLines = _answerSpaceLines AnswerSpaceLines = _answerSpaceLines
}; };
private async Task OpenTokenDialog()
{
if (_isDisposed)
return;
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
var parameters = new DialogParameters<PrintTokenInsertDialog>
{
{ x => x.EntityType, _entityType },
{ x => x.ImportedFieldNames, _importedTokenNames },
{ x => x.OnInsert, EventCallback.Factory.Create<string>(this, InsertToken) }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
await DialogService.ShowAsync<PrintTokenInsertDialog>("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() private async Task Preview()
{ {
if (_isDisposed || !CanPreview) if (_isDisposed)
return; 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; _isBusy = true;
try try
{ {
var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token); await RefreshImportedTokenNamesAsync(token);
var note = SelectedTemplateNote!;
_pages = await NotePrintService.PreviewAsync( _pages = await NotePrintService.PreviewAsync(
new NotePrintRequest new NotePrintRequest
{ {
EntityType = _entityType, EntityType = _entityType,
TemplateMarkdown = note.Content ?? string.Empty, TemplateMarkdown = _templateMarkdown,
Filters = BuildFilters(), Filters = BuildFilters(),
ImportedFieldCatalog = _importedTokenNames ImportedFieldCatalog = _importedTokenNames
}, },
@@ -553,12 +659,8 @@ else
_previewStale = false; _previewStale = false;
_didPreview = true; _didPreview = true;
if (_pages.Count > 75 && !_isDisposed) if (!_isDisposed)
{ await OpenPreviewDialogAsync();
Snackbar.Add(
$"This preview has {_pages.Count} pages. Printing a large set can be slow.",
Severity.Warning);
}
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -578,6 +680,46 @@ else
} }
} }
private async Task OpenPreviewDialogAsync()
{
await ClosePreviewDialogAsync();
var parameters = new DialogParameters<PrintPreviewDialog>
{
{ 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<PrintPreviewDialog>("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() private async Task Print()
{ {
if (_isDisposed || _previewStale || _pages.Count == 0) if (_isDisposed || _previewStale || _pages.Count == 0)
@@ -595,40 +737,33 @@ else
} }
} }
private async Task CopyToken(string token) private async Task SavePreset()
{ {
if (_isDisposed) if (_isDisposed)
return; return;
try await FlushEditorAsync();
{ if (!CanPreview)
await ClipboardService.WriteTextAsync($"{{{{{token}}}}}");
if (!_isDisposed)
Snackbar.Add($"Copied {FormatToken(token)}", Severity.Info);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{ {
if (!_isDisposed) 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 (_selectedPresetId.HasValue)
{ {
if (_isDisposed || !CanSaveAs || !_selectedNoteId.HasValue) await UpdateSelectedPresetAsync();
return;
}
var name = await PromptForPresetNameAsync();
if (string.IsNullOrWhiteSpace(name) || _isDisposed)
return; return;
_isBusy = true; _isBusy = true;
try try
{ {
var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var name = _saveAsName.Trim();
if (await PrintPresetService.NameExistsAsync(name, null, token)) if (await PrintPresetService.NameExistsAsync(name, null, token))
{ {
Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning); Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning);
@@ -639,7 +774,7 @@ else
new PrintPreset new PrintPreset
{ {
Name = name, Name = name,
NoteId = _selectedNoteId.Value, TemplateMarkdown = _templateMarkdown,
EntityType = _entityType, EntityType = _entityType,
FiltersJson = BuildFilters().ToJson() FiltersJson = BuildFilters().ToJson()
}, },
@@ -647,6 +782,7 @@ else
_presets = [.. await PrintPresetService.GetAllAsync(token)]; _presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = created.Id; _selectedPresetId = created.Id;
CaptureSnapshot();
if (!_isDisposed) if (!_isDisposed)
Snackbar.Add($"Saved print preset '{name}'.", Severity.Success); Snackbar.Add($"Saved print preset '{name}'.", Severity.Success);
} }
@@ -668,9 +804,26 @@ else
} }
} }
private async Task UpdatePreset() private async Task<string?> PromptForPresetNameAsync()
{ {
if (_isDisposed || !_selectedPresetId.HasValue || !_selectedNoteId.HasValue || !CanPreview) var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<PrintPresetNameDialog>("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; return;
_isBusy = true; _isBusy = true;
@@ -686,15 +839,16 @@ else
{ {
Id = existing.Id, Id = existing.Id,
Name = existing.Name, Name = existing.Name,
NoteId = _selectedNoteId.Value, TemplateMarkdown = _templateMarkdown,
EntityType = _entityType, EntityType = _entityType,
FiltersJson = BuildFilters().ToJson() FiltersJson = BuildFilters().ToJson()
}, },
token); token);
_presets = [.. await PrintPresetService.GetAllAsync(token)]; _presets = [.. await PrintPresetService.GetAllAsync(token)];
CaptureSnapshot();
if (!_isDisposed) if (!_isDisposed)
Snackbar.Add($"Updated print preset '{existing.Name}'.", Severity.Success); Snackbar.Add($"Saved print preset '{existing.Name}'.", Severity.Success);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -705,7 +859,7 @@ else
catch (Exception ex) catch (Exception ex)
{ {
if (!_isDisposed) if (!_isDisposed)
Snackbar.Add($"Could not update: {ex.Message}", Severity.Error); Snackbar.Add($"Could not save: {ex.Message}", Severity.Error);
} }
finally finally
{ {
@@ -739,6 +893,7 @@ else
await PrintPresetService.DeleteAsync(existing.Id, token); await PrintPresetService.DeleteAsync(existing.Id, token);
_presets = [.. await PrintPresetService.GetAllAsync(token)]; _presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = null; _selectedPresetId = null;
CaptureSnapshot();
if (!_isDisposed) if (!_isDisposed)
Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info); Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info);
} }
@@ -765,6 +920,9 @@ else
if (!_isDisposed) if (!_isDisposed)
{ {
_isDisposed = true; _isDisposed = true;
_navigationRegistration?.Dispose();
_navigationRegistration = null;
await ClosePreviewDialogAsync();
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose(); _cancellationTokenSource?.Dispose();
_cancellationTokenSource = null; _cancellationTokenSource = null;
@@ -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";
<MudContainer Class="@pageClass" Style="@PrintPageStyle">
<div class="markdown-content">
@((MarkupString)page.Html)
</div>
</MudContainer>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> Pages { get; set; } = [];
[Parameter]
public bool NewPagePerRecord { get; set; } = true;
[Parameter]
public string PrintPageStyle { get; set; } = string.Empty;
}
@@ -0,0 +1,37 @@
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_name"
Label="Preset name"
Variant="Variant.Outlined"
Immediate="true"
MaxLength="100"
Autofocus="true" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Save"
Disabled="@string.IsNullOrWhiteSpace(_name)">
Save
</MudButton>
</DialogActions>
</MudDialog>
@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();
}
@@ -0,0 +1,60 @@
@using WebApp.Services
<MudDialog Class="no-print">
<DialogContent>
@if (Pages.Count == 0)
{
<MudText Class="mud-text-secondary">No matching records for these filters.</MudText>
}
else
{
@if (Pages.Count > 75)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
This preview has @Pages.Count pages. Printing a large set can be slow.
</MudAlert>
}
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">
@Pages.Count page@(Pages.Count == 1 ? "" : "s")
</MudText>
<PrintPageStack Pages="Pages"
NewPagePerRecord="NewPagePerRecord"
PrintPageStyle="@PrintPageStyle" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Print"
OnClick="PrintAsync"
Disabled="@(Pages.Count == 0)">
Print
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> 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();
}
@@ -0,0 +1,105 @@
@using Core.Printing
@inject ClipboardService ClipboardService
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_search"
Label="Search tokens"
Variant="Variant.Outlined"
Immediate="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="mb-3" />
@if (!HasAnyMatches)
{
<MudText Class="mud-text-secondary">No tokens match this search.</MudText>
}
else
{
@foreach (var group in TokenGroups)
{
var tokens = Visible(group.Tokens);
if (tokens.Count == 0)
continue;
<MudText Typo="Typo.caption" Class="mud-text-secondary">@group.Label</MudText>
<div class="mb-2">
<TokenChips Tokens="tokens" Insert="Insert" Copy="Copy" />
</div>
}
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public PrintEntityType EntityType { get; set; }
[Parameter]
public IReadOnlyList<string> ImportedFieldNames { get; set; } = [];
[Parameter]
public EventCallback<string> OnInsert { get; set; }
private string _search = string.Empty;
private IEnumerable<(string Label, IReadOnlyList<string> 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<string> Visible(IReadOnlyList<string> 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();
}
@@ -0,0 +1,27 @@
@foreach (var token in Tokens)
{
var name = token;
<span class="d-inline-flex align-center mr-1 mb-1">
<MudChip T="string"
Size="Size.Small"
OnClick="() => Insert.InvokeAsync(name)">
{{@name}}
</MudChip>
<MudTooltip Text="Copy">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="() => Copy.InvokeAsync(name)" />
</MudTooltip>
</span>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<string> Tokens { get; set; } = [];
[Parameter]
public EventCallback<string> Insert { get; set; }
[Parameter]
public EventCallback<string> Copy { get; set; }
}
@@ -38,4 +38,55 @@ public class MarkdownTablePasteService
_logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown"); _logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown");
} }
} }
public async Task<string?> GetValueAsync(string editorId)
{
try
{
return await _jsRuntime.InvokeAsync<string?>("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<bool> SetValueAsync(string editorId, string text)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("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<bool> InsertAtCursorAsync(string editorId, string text)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("markdownTablePaste.insertAtCursor", editorId, text);
}
catch (JSDisconnectedException)
{
return false;
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to insert into markdown editor {EditorId}", editorId);
return false;
}
}
} }
+2 -4
View File
@@ -19,7 +19,6 @@ public class PrintPresetService : IPrintPresetService
{ {
return await _context.PrintPresets return await _context.PrintPresets
.AsNoTracking() .AsNoTracking()
.Include(p => p.Note)
.OrderBy(p => p.Name) .OrderBy(p => p.Name)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -28,7 +27,6 @@ public class PrintPresetService : IPrintPresetService
{ {
return await _context.PrintPresets return await _context.PrintPresets
.AsNoTracking() .AsNoTracking()
.Include(p => p.Note)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken); .FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
} }
@@ -48,7 +46,7 @@ public class PrintPresetService : IPrintPresetService
{ {
preset.Name = preset.Name.Trim(); preset.Name = preset.Name.Trim();
preset.UpdatedAt = DateTime.UtcNow; preset.UpdatedAt = DateTime.UtcNow;
preset.Note = null!; preset.TemplateMarkdown ??= string.Empty;
if (await NameExistsAsync(preset.Name, null, cancellationToken)) if (await NameExistsAsync(preset.Name, null, cancellationToken))
throw new InvalidOperationException($"A print preset named '{preset.Name}' already exists."); 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."); throw new InvalidOperationException($"A print preset named '{name}' already exists.");
existing.Name = name; existing.Name = name;
existing.NoteId = preset.NoteId; existing.TemplateMarkdown = preset.TemplateMarkdown ?? string.Empty;
existing.EntityType = preset.EntityType; existing.EntityType = preset.EntityType;
existing.FiltersJson = preset.FiltersJson; existing.FiltersJson = preset.FiltersJson;
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
+18
View File
@@ -16,6 +16,16 @@
display: none !important; display: none !important;
} }
.print-only {
display: block !important;
}
.mud-overlay,
.mud-dialog-container,
.mud-overlay-dialog {
display: none !important;
}
.nobrk { .nobrk {
break-inside: avoid; break-inside: avoid;
} }
@@ -375,6 +385,14 @@
height: auto; height: auto;
} }
.print-only {
display: none;
}
#page-printer-editor .EasyMDEContainer .CodeMirror {
min-height: 16rem;
}
.note-print-page { .note-print-page {
--print-font-size: 12pt; --print-font-size: 12pt;
--print-answer-lines: 3; --print-answer-lines: 3;
+99 -71
View File
@@ -2,6 +2,101 @@
// Handles both HTML tables (from Google Sheets) and tab-separated values // Handles both HTML tables (from Google Sheets) and tab-separated values
window.markdownTablePaste = { 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. * Initializes paste handler for a MarkdownEditor instance.
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler. * Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
@@ -13,78 +108,11 @@ window.markdownTablePaste = {
const tryInitialize = function() { const tryInitialize = function() {
attempts++; attempts++;
const found = window.markdownTablePaste.findEditor(editorId);
let textarea = null; const textarea = found.textarea;
let codeMirror = null; const codeMirror = found.codeMirror;
// 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;
}
}
}
if (textarea || codeMirror) {
const target = codeMirror || textarea; const target = codeMirror || textarea;
if (target) { if (target) {
+13 -11
View File
@@ -1,16 +1,16 @@
# Page printer # Page printer
**Created:** 2026-08-29 **Created:** 2026-08-29
**Last updated:** 2026-08-31 **Last updated:** 2026-09-03
**Description:** Merge a markdown note onto students, teams, or events and print one page per match. Save the recipe as a print preset. **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 ## Where to open it
Sign in and go to **Tools → Page printer** (`/print`). 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 ```markdown
# Interview — {{FirstName}} {{LastName}} # 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). 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 ## Filters
Pick **Students**, **Teams**, or **Events**, then optional 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 ## 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. 1. Write the template, set the entity and filters.
2. Enter a name and click **Save as**. 2. Click **Save**. If no preset is selected, enter a name in the dialog.
3. Later, choose a preset, click **Preview**, then **Print**. 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 ## 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. **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. **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. If Preview finds more than 75 matches, a warning is shown in the preview lightbox.
+5 -4
View File
@@ -1,17 +1,18 @@
# Page printer plan # Page printer plan
**Created:** 2026-08-29 **Created:** 2026-08-29
**Last updated:** 2026-08-30 **Last updated:** 2026-09-03
**Description:** Implementation notes for the Tools page printer (markdown note merge + print presets). **Description:** Implementation notes for the Tools page printer (markdown merge + print presets).
User-facing steps: [docs/instructions/page-printer.md](../instructions/page-printer.md). User-facing steps: [docs/instructions/page-printer.md](../instructions/page-printer.md).
## Built ## Built
- Core merge: `{{tokens}}`, `{{PageBreak}}`, `{{AnswerSpace}}`, print presets JSON - 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 pages sort by last name, then first name
- Student event-rank tokens: `{{Rank1}}`–`{{Rank10}}`, `.ShortName` / `.Attributes`, and `{{RankedEvents}}` badges - Student event-rank tokens: `{{Rank1}}`–`{{Rank10}}`, `.ShortName` / `.Attributes`, and `{{RankedEvents}}` badges
- Event `{{RankedStudents}}` badges (students who ranked the event) and `{{EventAttributes}}` - Event `{{RankedStudents}}` badges (students who ranked the event) and `{{EventAttributes}}`
- `{{Legend}}` from the shared attribute-mark catalog (`EventAttributeMarks`) - `{{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)