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:
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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");""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user