feat: add a Tools page printer for note merge and print presets

Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 15:38:43 -04:00
co-authored by Cursor
parent 4cfd85b902
commit 3f50d6e635
39 changed files with 2199 additions and 69 deletions
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using Core.Printing;
namespace Core.Entities;
/// <summary>
/// Saved page-printer recipe: template note, entity type, and filters. Merged output is not stored.
/// </summary>
public class PrintPreset
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = null!;
public int NoteId { get; set; }
public Note Note { get; set; } = null!;
public PrintEntityType EntityType { get; set; }
public string FiltersJson { get; set; } = "{}";
public DateTime UpdatedAt { get; set; }
}
+47 -12
View File
@@ -7,7 +7,7 @@ namespace Core.Notes;
/// </summary> /// </summary>
public static class ImportedFieldsTable public static class ImportedFieldsTable
{ {
public const string Heading = "## Imported fields"; public const string Heading = "## Additional fields";
public static string NormalizeValue(string? raw) public static string NormalizeValue(string? raw)
{ {
@@ -18,7 +18,7 @@ public static class ImportedFieldsTable
} }
/// <summary> /// <summary>
/// Reads Field/Value rows from the Imported fields section. /// Reads Field/Value rows from the Additional fields section.
/// </summary> /// </summary>
public static List<ImportedField> ParseFields(string? markdown) public static List<ImportedField> ParseFields(string? markdown)
{ {
@@ -50,7 +50,7 @@ public static class ImportedFieldsTable
} }
/// <summary> /// <summary>
/// Upserts incoming fields into the Imported fields section. Incoming values win. /// Upserts incoming fields into the Additional fields section. Incoming values win.
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes. /// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
/// </summary> /// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming) public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
@@ -126,7 +126,7 @@ public static class ImportedFieldsTable
} }
/// <summary> /// <summary>
/// Unique imported field names across notes, first-seen casing, sorted A–Z. /// Unique additional-field names across notes, first-seen casing, sorted A–Z.
/// </summary> /// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns) public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{ {
@@ -150,11 +150,10 @@ public static class ImportedFieldsTable
if (string.IsNullOrEmpty(markdown)) if (string.IsNullOrEmpty(markdown))
return null; return null;
var start = IndexOfHeading(markdown); if (!TryFindHeading(markdown, out var start, out var headingLength))
if (start < 0)
return null; return null;
var afterHeading = start + Heading.Length; var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(markdown, afterHeading); var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading]; return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
} }
@@ -164,8 +163,7 @@ public static class ImportedFieldsTable
if (string.IsNullOrWhiteSpace(existingMarkdown)) if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine; return section.TrimEnd() + Environment.NewLine;
var start = IndexOfHeading(existingMarkdown); if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
if (start < 0)
{ {
var prefix = existingMarkdown.TrimEnd(); var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix) return string.IsNullOrEmpty(prefix)
@@ -173,7 +171,7 @@ public static class ImportedFieldsTable
: prefix + Environment.NewLine + Environment.NewLine + section; : prefix + Environment.NewLine + Environment.NewLine + section;
} }
var afterHeading = start + Heading.Length; var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading); var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd(); var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart(); var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
@@ -199,8 +197,45 @@ public static class ImportedFieldsTable
return builder.ToString(); return builder.ToString();
} }
private static int IndexOfHeading(string markdown) => /// <summary>
markdown.IndexOf(Heading, StringComparison.Ordinal); /// First <c>## … fields</c> heading (any prefix). New sections are written as <see cref="Heading"/>.
/// </summary>
private static bool TryFindHeading(string markdown, out int start, out int headingLength)
{
var index = 0;
while (index < markdown.Length)
{
var lineEnd = markdown.IndexOf('\n', index);
var end = lineEnd < 0 ? markdown.Length : lineEnd;
var line = markdown[index..end].TrimEnd('\r');
if (IsFieldsHeading(line))
{
start = index;
headingLength = line.Length;
return true;
}
if (lineEnd < 0)
break;
index = lineEnd + 1;
}
start = -1;
headingLength = 0;
return false;
}
private static bool IsFieldsHeading(string line)
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("## ", StringComparison.Ordinal)
|| trimmed.StartsWith("###", StringComparison.Ordinal))
return false;
var title = trimmed[3..].Trim();
return title.EndsWith(" fields", StringComparison.OrdinalIgnoreCase)
|| title.Equals("fields", StringComparison.OrdinalIgnoreCase);
}
private static int FindNextHeading(string markdown, int startIndex) private static int FindNextHeading(string markdown, int startIndex)
{ {
+73
View File
@@ -0,0 +1,73 @@
using System.Text.RegularExpressions;
namespace Core.Printing;
/// <summary>
/// Replaces <c>{{Token}}</c> placeholders from a case-insensitive map.
/// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space after HTML conversion.
/// </summary>
public static class NoteTemplateMerger
{
public const string PageBreakToken = "PageBreak";
public const string PageBreakSentinel = "<!--tsa-page-break-->";
public const string PageBreakHtml = "<div class=\"pagebreak\"></div>";
public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\"></div>";
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens =
[
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml)
];
private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
public static string Merge(string? template, IReadOnlyDictionary<string, string> tokens)
{
if (string.IsNullOrEmpty(template))
return string.Empty;
ArgumentNullException.ThrowIfNull(tokens);
return TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value.Trim();
if (key.Length == 0)
return match.Value;
foreach (var layout in LayoutTokens)
{
if (key.Equals(layout.Name, StringComparison.OrdinalIgnoreCase))
return layout.Sentinel;
}
return tokens.TryGetValue(key, out var value)
? value ?? string.Empty
: match.Value;
});
}
/// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary>
public static string ApplyLayout(string? html)
{
if (string.IsNullOrEmpty(html))
return string.Empty;
foreach (var layout in LayoutTokens)
{
html = html
.Replace($"<p>{layout.Sentinel}</p>", layout.Html, StringComparison.Ordinal)
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal);
}
return html;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Core.Printing;
/// <summary>
/// Entity a print preset merges a note onto.
/// </summary>
public enum PrintEntityType
{
Student,
Team,
Event
}
+79
View File
@@ -0,0 +1,79 @@
namespace Core.Printing;
/// <summary>
/// Built-in merge token names. Imported student-note field names are supplied at runtime.
/// </summary>
public static class PrintFieldCatalog
{
public static readonly string[] Layout =
[
NoteTemplateMerger.PageBreakToken,
NoteTemplateMerger.AnswerSpaceToken
];
public static readonly string[] Chapter =
[
"Chapter.Name",
"Chapter.ShortName",
"Chapter.CompetitionYear",
"Chapter.YearlyTheme",
"Chapter.StateAbbrev"
];
public static readonly string[] Student =
[
"FirstName",
"LastName",
"Name",
"LastNameFirstName",
"Grade",
"TsaYear",
"Email",
"PhoneNumber",
"StateId",
"RegionalId",
"NationalId",
"OfficerRole"
];
public static readonly string[] Team =
[
"Identifier",
"Name",
"EventName",
"EventShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"Description",
"Theme"
];
public static readonly string[] Event =
[
"Name",
"ShortName",
"EventFormat",
"TeamSize",
"Eligibility",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
"Description",
"Theme",
"Documentation",
"Notes"
];
public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) =>
entityType switch
{
PrintEntityType.Student => Student,
PrintEntityType.Team => Team,
PrintEntityType.Event => Event,
_ => []
};
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) =>
[.. Chapter, .. EntityTokens(entityType)];
}
+94
View File
@@ -0,0 +1,94 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Filter payload stored on a <see cref="PrintPreset"/>. Unused fields stay null.
/// </summary>
public class PrintPresetFilters
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public int? Grade { get; set; }
public int? TsaYear { get; set; }
/// <summary>
/// <c>true</c> officers only, <c>false</c> non-officers only, <c>null</c> any.
/// </summary>
public bool? IsOfficer { get; set; }
public string? TeamIdentifierContains { get; set; }
public string? EventNameContains { get; set; }
public EventFormat? EventFormat { get; set; }
/// <summary>
/// <c>true</c> regional only, <c>false</c> non-regional only, <c>null</c> any.
/// </summary>
public bool? RegionalOnly { get; set; }
/// <summary>
/// When true (default), each merged record starts a new printed page.
/// <c>{{PageBreak}}</c> in the template still works either way.
/// </summary>
public bool NewPagePerRecord { get; set; } = true;
public const int DefaultFontSizePt = 12;
public const int MinFontSizePt = 9;
public const int MaxFontSizePt = 18;
public const int DefaultAnswerSpaceLines = 3;
public const int MinAnswerSpaceLines = 1;
public const int MaxAnswerSpaceLines = 8;
/// <summary>
/// Body font size in points for merged pages.
/// </summary>
public int FontSizePt { get; set; } = DefaultFontSizePt;
/// <summary>
/// Ruled write-in lines for each <c>{{AnswerSpace}}</c>.
/// </summary>
public int AnswerSpaceLines { get; set; } = DefaultAnswerSpaceLines;
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static PrintPresetFilters FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new PrintPresetFilters();
var filters = JsonSerializer.Deserialize<PrintPresetFilters>(json, JsonOptions)
?? new PrintPresetFilters();
filters.ClampPrintOptions();
return filters;
}
public void ClampPrintOptions()
{
FontSizePt = Math.Clamp(FontSizePt, MinFontSizePt, MaxFontSizePt);
AnswerSpaceLines = Math.Clamp(AnswerSpaceLines, MinAnswerSpaceLines, MaxAnswerSpaceLines);
}
public static string ToTriState(bool? value) => value switch
{
true => "yes",
false => "no",
_ => "any"
};
public static bool? FromTriState(string? value) => value switch
{
"yes" => true,
"no" => false,
_ => null
};
}
+55
View File
@@ -0,0 +1,55 @@
namespace Core.Printing;
/// <summary>
/// Builds a case-insensitive token map. Fill order is imported, then entity, then chapter
/// so built-in names win over an imported column with the same name.
/// </summary>
public static class PrintTokenMap
{
public static Dictionary<string, string> Create() =>
new(StringComparer.OrdinalIgnoreCase);
public static Dictionary<string, string> Build(
IReadOnlyDictionary<string, string?>? imported,
IReadOnlyDictionary<string, string?>? entity,
IReadOnlyDictionary<string, string?>? chapter)
{
var map = Create();
Apply(map, imported);
Apply(map, entity);
Apply(map, chapter);
return map;
}
public static void Apply(IDictionary<string, string> map, IReadOnlyDictionary<string, string?>? values)
{
if (values is null)
return;
foreach (var (key, value) in values)
{
if (string.IsNullOrWhiteSpace(key))
continue;
map[key] = Escape(value);
}
}
/// <summary>
/// Treats substituted values as plain text so they cannot change markdown or inject HTML.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("*", "\\*", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal)
.Replace("`", "\\`", StringComparison.Ordinal)
.Replace("[", "\\[", StringComparison.Ordinal)
.Replace("]", "\\]", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
}
+1
View File
@@ -15,6 +15,7 @@ namespace Data
public DbSet<Note> Notes { get; set; } public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; } public DbSet<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; } public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public DbSet<PrintPreset> PrintPresets { get; set; }
public AppDbContext() public AppDbContext()
{ {
@@ -0,0 +1,35 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations;
public class PrintPresetConfiguration : IEntityTypeConfiguration<PrintPreset>
{
public void Configure(EntityTypeBuilder<PrintPreset> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(100);
builder.HasIndex(p => p.Name)
.IsUnique();
builder.Property(p => p.EntityType)
.HasConversion<string>()
.HasMaxLength(32)
.IsRequired();
builder.Property(p => p.FiltersJson)
.IsRequired()
.HasColumnType("TEXT");
builder.HasOne(p => p.Note)
.WithMany()
.HasForeignKey(p => p.NoteId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
}
}
@@ -0,0 +1,60 @@
using System;
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260830040000_AddPrintPresets")]
public partial class AddPrintPresets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PrintPresets",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
EntityType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
FiltersJson = table.Column<string>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PrintPresets", x => x.Id);
table.ForeignKey(
name: "FK_PrintPresets_Notes_NoteId",
column: x => x.NoteId,
principalTable: "Notes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_PrintPresets_Name",
table: "PrintPresets",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PrintPresets_NoteId",
table: "PrintPresets",
column: "NoteId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PrintPresets");
}
}
}
@@ -177,6 +177,42 @@ namespace Data.Migrations
b.ToTable("EventOccurrences"); b.ToTable("EventOccurrences");
}); });
modelBuilder.Entity("Core.Entities.PrintPreset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("NoteId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("NoteId");
b.ToTable("PrintPresets");
});
modelBuilder.Entity("Core.Entities.Note", b => modelBuilder.Entity("Core.Entities.Note", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -467,6 +503,17 @@ 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")
+28
View File
@@ -47,6 +47,34 @@ public class ImportedFieldsTable_Tests
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes")); Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua")); Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
Assert.That(result.Markdown, Does.Contain("Freeform note")); Assert.That(result.Markdown, Does.Contain("Freeform note"));
Assert.That(result.Markdown, Does.Contain(ImportedFieldsTable.Heading));
Assert.That(result.Markdown, Does.Not.Contain("## Imported fields"));
}
[Test]
public void ParseFields_ReadsAnyFieldsHeading()
{
foreach (var heading in new[] { "## Additional fields", "## Imported fields", "## Extra fields" })
{
var markdown = $"""
{heading}
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Allergies"), Is.EqualTo("peanuts"), heading);
}
var otherHeading = """
## Advisor comments
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(otherHeading, "Allergies"), Is.Null);
} }
[Test] [Test]
+119
View File
@@ -0,0 +1,119 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class NoteTemplateMerger_Tests
{
[Test]
public void Merge_ReplacesKnownTokens()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
tokens["LastName"] = "Cole";
var result = NoteTemplateMerger.Merge("Hello {{FirstName}} {{LastName}}", tokens);
Assert.That(result, Is.EqualTo("Hello Aria Cole"));
}
[Test]
public void Merge_SupportsDottedAndSpacedNames()
{
var tokens = PrintTokenMap.Create();
tokens["Chapter.Name"] = "North TSA";
tokens["Interview Time"] = "3:20-3:35";
var result = NoteTemplateMerger.Merge(
"{{Chapter.Name}} at {{Interview Time}}",
tokens);
Assert.That(result, Is.EqualTo("North TSA at 3:20-3:35"));
}
[Test]
public void Merge_IsCaseAndWhitespaceTolerant()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
var result = NoteTemplateMerger.Merge("{{ firstname }}", tokens);
Assert.That(result, Is.EqualTo("Aria"));
}
[Test]
public void Merge_LeavesUnknownTokensInPlace()
{
var tokens = PrintTokenMap.Create();
tokens["FirstName"] = "Aria";
var result = NoteTemplateMerger.Merge("{{FirstName}} {{Missing}}", tokens);
Assert.That(result, Is.EqualTo("Aria {{Missing}}"));
}
[Test]
public void Merge_KnownEmptyBecomesBlank()
{
var tokens = PrintTokenMap.Create();
tokens["Interview Time"] = string.Empty;
var result = NoteTemplateMerger.Merge("Time: {{Interview Time}}.", tokens);
Assert.That(result, Is.EqualTo("Time: ."));
}
[Test]
public void Merge_EmptyTemplate_IsEmpty()
{
Assert.That(NoteTemplateMerger.Merge(null, PrintTokenMap.Create()), Is.EqualTo(string.Empty));
Assert.That(NoteTemplateMerger.Merge("", PrintTokenMap.Create()), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_PageBreak_IsSentinel()
{
var result = NoteTemplateMerger.Merge(
"Above\n{{PageBreak}}\nBelow",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.PageBreakSentinel));
Assert.That(result, Does.Not.Contain("{{PageBreak}}"));
}
[Test]
public void ApplyLayout_ReplacesSentinelAndParagraphWrap()
{
var raw = NoteTemplateMerger.ApplyLayout(
$"x{NoteTemplateMerger.PageBreakSentinel}y");
var wrapped = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.PageBreakSentinel}</p>");
Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.PageBreakHtml}y"));
Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.PageBreakHtml));
}
[Test]
public void Merge_AnswerSpace_IsSentinel()
{
var result = NoteTemplateMerger.Merge(
"Q?\n{{AnswerSpace}}\nNext",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.AnswerSpaceSentinel));
Assert.That(result, Does.Not.Contain("{{AnswerSpace}}"));
}
[Test]
public void ApplyLayout_ReplacesAnswerSpaceSentinelAndParagraphWrap()
{
var raw = NoteTemplateMerger.ApplyLayout(
$"x{NoteTemplateMerger.AnswerSpaceSentinel}y");
var wrapped = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.AnswerSpaceSentinel}</p>");
Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.AnswerSpaceHtml}y"));
Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.AnswerSpaceHtml));
}
}
+39
View File
@@ -0,0 +1,39 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintFieldCatalog_Tests
{
[Test]
public void BuiltInFor_StudentIncludesChapterAndStudentTokens()
{
var tokens = PrintFieldCatalog.BuiltInFor(PrintEntityType.Student);
Assert.That(tokens, Does.Contain("FirstName"));
Assert.That(tokens, Does.Contain("Chapter.ShortName"));
Assert.That(tokens, Does.Not.Contain("Interview Time"));
}
[Test]
public void BuiltInFor_TeamAndEventHaveExpectedNames()
{
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
}
[Test]
public void Layout_IncludesPageBreakAndAnswerSpace()
{
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken));
}
[Test]
public void EntityTokens_MatchesCatalogArrays()
{
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Is.EqualTo(PrintFieldCatalog.Student));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Team), Is.EqualTo(PrintFieldCatalog.Team));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Event), Is.EqualTo(PrintFieldCatalog.Event));
}
}
@@ -0,0 +1,54 @@
using Core.Entities;
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintPresetFilters_Tests
{
[Test]
public void JsonRoundTrip_PreservesStudentAndImportedFilters()
{
var filters = new PrintPresetFilters
{
Grade = 9,
TsaYear = 1,
IsOfficer = true,
RegionalOnly = true,
EventFormat = EventFormat.Team,
NewPagePerRecord = false,
FontSizePt = 14,
AnswerSpaceLines = 4
};
var restored = PrintPresetFilters.FromJson(filters.ToJson());
Assert.That(restored.Grade, Is.EqualTo(9));
Assert.That(restored.TsaYear, Is.EqualTo(1));
Assert.That(restored.IsOfficer, Is.True);
Assert.That(restored.RegionalOnly, Is.True);
Assert.That(restored.EventFormat, Is.EqualTo(EventFormat.Team));
Assert.That(restored.NewPagePerRecord, Is.False);
Assert.That(restored.FontSizePt, Is.EqualTo(14));
Assert.That(restored.AnswerSpaceLines, Is.EqualTo(4));
}
[Test]
public void FromJson_EmptyOrInvalid_ReturnsDefaults()
{
Assert.That(PrintPresetFilters.FromJson(null).TsaYear, Is.Null);
Assert.That(PrintPresetFilters.FromJson("{}").Grade, Is.Null);
Assert.That(PrintPresetFilters.FromJson("{}").NewPagePerRecord, Is.True);
Assert.That(PrintPresetFilters.FromJson("{}").FontSizePt, Is.EqualTo(PrintPresetFilters.DefaultFontSizePt));
Assert.That(PrintPresetFilters.FromJson("{}").AnswerSpaceLines, Is.EqualTo(PrintPresetFilters.DefaultAnswerSpaceLines));
}
[Test]
public void FromJson_ClampsPrintOptions()
{
var restored = PrintPresetFilters.FromJson("""{"fontSizePt":99,"answerSpaceLines":0}""");
Assert.That(restored.FontSizePt, Is.EqualTo(PrintPresetFilters.MaxFontSizePt));
Assert.That(restored.AnswerSpaceLines, Is.EqualTo(PrintPresetFilters.MinAnswerSpaceLines));
}
}
+59
View File
@@ -0,0 +1,59 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class PrintTokenMap_Tests
{
[Test]
public void Build_BuiltInWinsOverImportedSameName()
{
var map = PrintTokenMap.Build(
new Dictionary<string, string?> { ["Grade"] = "imported" },
new Dictionary<string, string?> { ["Grade"] = "9" },
null);
Assert.That(map["Grade"], Is.EqualTo("9"));
}
[Test]
public void Build_IncludesAllImportedCatalogKeys()
{
var imported = new Dictionary<string, string?>
{
["Interview Time"] = "3:20-3:35",
["Application"] = null
};
var map = PrintTokenMap.Build(imported, null, null);
Assert.That(map.ContainsKey("Interview Time"), Is.True);
Assert.That(map["Interview Time"], Is.EqualTo("3:20-3:35"));
Assert.That(map.ContainsKey("Application"), Is.True);
Assert.That(map["Application"], Is.EqualTo(string.Empty));
}
[Test]
public void Escape_MasksMarkdownAndHtml()
{
var escaped = PrintTokenMap.Escape("A *B* <script>");
Assert.That(escaped, Does.Contain("\\*"));
Assert.That(escaped, Does.Contain("&lt;"));
Assert.That(escaped, Does.Contain("&gt;"));
Assert.That(escaped, Does.Not.Contain("<script>"));
}
[Test]
public void Merge_EscapedAsteriskDoesNotStayRaw()
{
var map = PrintTokenMap.Build(
null,
new Dictionary<string, string?> { ["FirstName"] = "A*ria" },
null);
var merged = NoteTemplateMerger.Merge("Hi {{FirstName}}", map);
Assert.That(merged, Is.EqualTo("Hi A\\*ria"));
}
}
@@ -321,7 +321,7 @@
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options); var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled) if (result is { Canceled: false })
{ {
// Refresh data if meeting was updated or deleted // Refresh data if meeting was updated or deleted
await RefreshMeetingHistories(); await RefreshMeetingHistories();
@@ -664,7 +664,7 @@
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success); Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
} }
} }
catch (JSException ex) catch (JSException)
{ {
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error); Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
} }
@@ -401,7 +401,7 @@
var result = await dialog.Result; var result = await dialog.Result;
// Refresh meeting history if dialog was saved // Refresh meeting history if dialog was saved
if (!result.Canceled && !_isDisposed) if (result is { Canceled: false } && !_isDisposed)
{ {
await LoadMeetingHistory(); await LoadMeetingHistory();
} }
@@ -11,12 +11,11 @@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center"> <MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center"> <MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Clear" <MudIconButton Icon="@Icons.Material.Filled.Clear"
Size="Size.Small" Size="Size.Small"
Class="@(removed ? "" : "d-none")" Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))" OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
Style="cursor: pointer;"> aria-label="Restore team" />
</MudIcon>
@{ @{
var teamMembers = TeamStudentNameFormatter.FormatStudentList( var teamMembers = TeamStudentNameFormatter.FormatStudentList(
team, team,
@@ -0,0 +1,773 @@
@page "/print"
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Printing
@using Core.Services
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject INotePrintService NotePrintService
@inject IPrintPresetService PrintPresetService
@inject IConfiguration Configuration
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JSRuntime
@inject ClipboardService ClipboardService
<div class="no-print">
<PageHeader Title="Page printer"
Description="Merge a markdown note onto students, teams, or events and print one page per match."
Icon="@Icons.Material.Filled.Print" />
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mb-4">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<MudGrid>
<MudItem xs="12" md="4">
<MudSelect T="int?"
Label="Print preset"
Value="_selectedPresetId"
ValueChanged="OnPresetSelected"
Clearable="true"
Variant="Variant.Outlined">
@foreach (var preset in _presets)
{
<MudSelectItem T="int?" Value="@preset.Id">@preset.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<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"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveAsPreset"
Disabled="@(_isBusy || !CanSaveAs)">
Save as
</MudButton>
<MudButton Variant="Variant.Outlined"
OnClick="UpdatePreset"
Disabled="@(_isBusy || !_selectedPresetId.HasValue || !CanPreview)">
Update
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Error"
OnClick="DeletePreset"
Disabled="@(_isBusy || !_selectedPresetId.HasValue)">
Delete
</MudButton>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="PrintEntityType"
Label="Entity"
Value="_entityType"
ValueChanged="OnEntityTypeChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="PrintEntityType.Student">Students</MudSelectItem>
<MudSelectItem Value="PrintEntityType.Team">Teams</MudSelectItem>
<MudSelectItem Value="PrintEntityType.Event">Events</MudSelectItem>
</MudSelect>
</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)
{
<MudItem xs="12" sm="4" md="3">
<MudNumericField T="int?"
Label="Grade"
Value="_grade"
ValueChanged="OnGradeChanged"
Variant="Variant.Outlined"
Min="5"
Max="12"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="4" md="3">
<MudNumericField T="int?"
Label="TSA year"
Value="_tsaYear"
ValueChanged="OnTsaYearChanged"
Variant="Variant.Outlined"
Min="1"
Max="12"
Clearable="true" />
</MudItem>
<MudItem xs="12" sm="4" md="3">
<MudSelect T="string"
Label="Officer"
Value="@_officerChoice"
ValueChanged="OnOfficerChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="@("any")">Any</MudSelectItem>
<MudSelectItem Value="@("yes")">Officers only</MudSelectItem>
<MudSelectItem Value="@("no")">Non-officers only</MudSelectItem>
</MudSelect>
</MudItem>
}
else if (_entityType == PrintEntityType.Team)
{
<MudItem xs="12" md="4">
<MudTextField T="string"
Value="@_teamIdentifierContains"
ValueChanged="OnTeamIdentifierChanged"
Label="Identifier contains"
Variant="Variant.Outlined"
Immediate="true" />
</MudItem>
}
else
{
<MudItem xs="12" sm="4">
<MudTextField T="string"
Value="@_eventNameContains"
ValueChanged="OnEventNameChanged"
Label="Name contains"
Variant="Variant.Outlined"
Immediate="true" />
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="EventFormat?"
Label="Event format"
Value="_eventFormat"
ValueChanged="OnEventFormatChanged"
Variant="Variant.Outlined"
Clearable="true">
@foreach (var format in Enum.GetValues<EventFormat>())
{
<MudSelectItem T="EventFormat?" Value="@format">@format</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="string"
Label="Regional"
Value="@_regionalChoice"
ValueChanged="OnRegionalChanged"
Variant="Variant.Outlined">
<MudSelectItem Value="@("any")">Any</MudSelectItem>
<MudSelectItem Value="@("yes")">Regional only</MudSelectItem>
<MudSelectItem Value="@("no")">Non-regional only</MudSelectItem>
</MudSelect>
</MudItem>
}
<MudItem xs="12">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="flex-wrap">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview"
Disabled="@(_isBusy || !CanPreview)">
Preview
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Print"
OnClick="Print"
Disabled="@(_isBusy || _previewStale || _pages.Count == 0)">
Print
</MudButton>
<MudCheckBox T="bool"
Value="@_newPagePerRecord"
ValueChanged="OnNewPagePerRecordChanged"
Label="New page per record"
Dense="true" />
<MudNumericField T="int"
Label="Font size (pt)"
Value="_fontSizePt"
ValueChanged="OnFontSizeChanged"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="PrintPresetFilters.MinFontSizePt"
Max="PrintPresetFilters.MaxFontSizePt"
Style="max-width: 8rem;" />
<MudNumericField T="int"
Label="Answer lines"
Value="_answerSpaceLines"
ValueChanged="OnAnswerSpaceLinesChanged"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="PrintPresetFilters.MinAnswerSpaceLines"
Max="PrintPresetFilters.MaxAnswerSpaceLines"
Style="max-width: 8rem;" />
@if (!_previewStale && _pages.Count > 0)
{
<MudText Typo="Typo.body2">@_pages.Count page@(_pages.Count == 1 ? "" : "s")</MudText>
}
else if (!_previewStale && _didPreview)
{
<MudText Typo="Typo.body2">No matches</MudText>
}
</MudStack>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-1">Tokens (click to copy)</MudText>
@foreach (var group in TokenGroups)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@group.Label</MudText>
<div class="mb-2">
@foreach (var token in group.Tokens)
{
<MudChip T="string" Size="Size.Small" OnClick="() => CopyToken(token)" Class="ma-1">@FormatToken(token)</MudChip>
}
</div>
}
</MudItem>
</MudGrid>
</MudPaper>
</div>
@if (_didPreview && _pages.Count == 0 && !_previewStale)
{
<MudText Class="no-print mud-text-secondary">No matching records for these filters.</MudText>
}
else
{
@for (var i = 0; i < _pages.Count; i++)
{
var page = _pages[i];
var isLast = i == _pages.Count - 1;
var pageClass = _newPagePerRecord && !isLast
? "note-print-page pagebreak"
: "note-print-page";
<MudContainer Class="@pageClass" Style="@PrintPageStyle">
<div class="markdown-content">
@((MarkupString)page.Html)
</div>
</MudContainer>
}
}
@code {
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
private bool _isLoading = true;
private bool _isBusy;
private bool _previewStale = true;
private bool _didPreview;
private List<Note> _templateNotes = [];
private List<PrintPreset> _presets = [];
private List<string> _importedTokenNames = [];
private IReadOnlyList<NotePrintPage> _pages = [];
private int? _selectedPresetId;
private int? _selectedNoteId;
private string _saveAsName = string.Empty;
private string? _templateWarning;
private PrintEntityType _entityType = PrintEntityType.Student;
private int? _grade;
private int? _tsaYear;
private string _officerChoice = "any";
private string? _teamIdentifierContains;
private string? _eventNameContains;
private EventFormat? _eventFormat;
private string _regionalChoice = "any";
private bool _newPagePerRecord = true;
private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt;
private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines;
private static string FormatToken(string token) => "{{" + token + "}}";
private string PrintPageStyle =>
$"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};";
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 && _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()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
if (_isDisposed)
return;
_isLoading = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var notes = await NotesService.GetNotesAsync();
_templateNotes =
[
.. notes.Where(n => !NoteNamingService.IsPageNote(n.Title))
.OrderBy(n => n.Title)
];
await RefreshImportedTokenNamesAsync(token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
finally
{
if (!_isDisposed)
_isLoading = false;
}
}
private void MarkStale()
{
_previewStale = true;
_pages = [];
}
private void OnEntityTypeChanged(PrintEntityType value)
{
_entityType = value;
_templateWarning = null;
MarkStale();
}
private void OnNoteSelected(int? value)
{
_selectedNoteId = value;
_templateWarning = null;
MarkStale();
}
private void OnGradeChanged(int? value)
{
_grade = value;
MarkStale();
}
private void OnTsaYearChanged(int? value)
{
_tsaYear = value;
MarkStale();
}
private void OnOfficerChanged(string value)
{
_officerChoice = value;
MarkStale();
}
private void OnTeamIdentifierChanged(string? value)
{
_teamIdentifierContains = value;
MarkStale();
}
private void OnEventNameChanged(string? value)
{
_eventNameContains = value;
MarkStale();
}
private void OnEventFormatChanged(EventFormat? value)
{
_eventFormat = value;
MarkStale();
}
private void OnRegionalChanged(string value)
{
_regionalChoice = value;
MarkStale();
}
private void OnNewPagePerRecordChanged(bool value)
{
_newPagePerRecord = value;
}
private void OnFontSizeChanged(int value)
{
_fontSizePt = Math.Clamp(value, PrintPresetFilters.MinFontSizePt, PrintPresetFilters.MaxFontSizePt);
}
private void OnAnswerSpaceLinesChanged(int value)
{
_answerSpaceLines = Math.Clamp(value, PrintPresetFilters.MinAnswerSpaceLines, PrintPresetFilters.MaxAnswerSpaceLines);
}
private async Task RefreshImportedTokenNamesAsync(CancellationToken token)
{
var indexFields = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
var discovered = await NotesService.GetImportedFieldNamesAsync(token);
_importedTokenNames =
[
.. indexFields
.Concat(discovered)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
];
}
private async Task OnPresetSelected(int? id)
{
_selectedPresetId = id;
if (!id.HasValue)
{
MarkStale();
return;
}
var preset = _presets.FirstOrDefault(p => p.Id == id.Value);
if (preset is null)
return;
ApplyPreset(preset);
MarkStale();
await Task.CompletedTask;
}
private void ApplyPreset(PrintPreset preset)
{
_saveAsName = preset.Name;
_entityType = preset.EntityType;
var filters = PrintPresetFilters.FromJson(preset.FiltersJson);
_grade = filters.Grade;
_tsaYear = filters.TsaYear;
_officerChoice = PrintPresetFilters.ToTriState(filters.IsOfficer);
_teamIdentifierContains = filters.TeamIdentifierContains;
_eventNameContains = filters.EventNameContains;
_eventFormat = filters.EventFormat;
_regionalChoice = PrintPresetFilters.ToTriState(filters.RegionalOnly);
_newPagePerRecord = filters.NewPagePerRecord;
_fontSizePt = filters.FontSizePt;
_answerSpaceLines = filters.AnswerSpaceLines;
var note = _templateNotes.FirstOrDefault(n => n.Id == preset.NoteId);
if (note is null || note.IsDeleted || string.IsNullOrWhiteSpace(note.Content))
{
_selectedNoteId = null;
_templateWarning = "This preset's template note is missing or empty. Choose another note.";
}
else
{
_selectedNoteId = note.Id;
_templateWarning = null;
}
}
private PrintPresetFilters BuildFilters() =>
new()
{
Grade = _entityType == PrintEntityType.Student ? _grade : null,
TsaYear = _entityType == PrintEntityType.Student ? _tsaYear : null,
IsOfficer = _entityType == PrintEntityType.Student ? PrintPresetFilters.FromTriState(_officerChoice) : null,
TeamIdentifierContains = _entityType == PrintEntityType.Team ? _teamIdentifierContains : null,
EventNameContains = _entityType == PrintEntityType.Event ? _eventNameContains : null,
EventFormat = _entityType == PrintEntityType.Event ? _eventFormat : null,
RegionalOnly = _entityType == PrintEntityType.Event ? PrintPresetFilters.FromTriState(_regionalChoice) : null,
NewPagePerRecord = _newPagePerRecord,
FontSizePt = _fontSizePt,
AnswerSpaceLines = _answerSpaceLines
};
private async Task Preview()
{
if (_isDisposed || !CanPreview)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
var note = SelectedTemplateNote!;
_pages = await NotePrintService.PreviewAsync(
new NotePrintRequest
{
EntityType = _entityType,
TemplateMarkdown = note.Content ?? string.Empty,
Filters = BuildFilters(),
ImportedFieldCatalog = _importedTokenNames
},
token);
_previewStale = false;
_didPreview = true;
if (_pages.Count > 75 && !_isDisposed)
{
Snackbar.Add(
$"This preview has {_pages.Count} pages. Printing a large set can be slow.",
Severity.Warning);
}
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Preview failed: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task Print()
{
if (_isDisposed || _previewStale || _pages.Count == 0)
return;
try
{
await JSRuntime.InvokeVoidAsync("window.print");
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
}
private async Task CopyToken(string token)
{
if (_isDisposed)
return;
try
{
await ClipboardService.WriteTextAsync($"{{{{{token}}}}}");
if (!_isDisposed)
Snackbar.Add($"Copied {FormatToken(token)}", Severity.Info);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error);
}
}
private async Task SaveAsPreset()
{
if (_isDisposed || !CanSaveAs || !_selectedNoteId.HasValue)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var name = _saveAsName.Trim();
if (await PrintPresetService.NameExistsAsync(name, null, token))
{
Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning);
return;
}
var created = await PrintPresetService.CreateAsync(
new PrintPreset
{
Name = name,
NoteId = _selectedNoteId.Value,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = created.Id;
if (!_isDisposed)
Snackbar.Add($"Saved print preset '{name}'.", Severity.Success);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not save: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task UpdatePreset()
{
if (_isDisposed || !_selectedPresetId.HasValue || !_selectedNoteId.HasValue || !CanPreview)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value);
if (existing is null)
return;
await PrintPresetService.UpdateAsync(
new PrintPreset
{
Id = existing.Id,
Name = existing.Name,
NoteId = _selectedNoteId.Value,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
if (!_isDisposed)
Snackbar.Add($"Updated print preset '{existing.Name}'.", Severity.Success);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not update: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
private async Task DeletePreset()
{
if (_isDisposed || !_selectedPresetId.HasValue)
return;
var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value);
if (existing is null)
return;
var confirmed = await DialogService.ShowMessageBox(
"Delete print preset",
(MarkupString)$"Delete <b>{existing.Name}</b>? This cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true || _isDisposed)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await PrintPresetService.DeleteAsync(existing.Id, token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = null;
if (!_isDisposed)
Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not delete: {ex.Message}", Severity.Error);
}
finally
{
if (!_isDisposed)
_isBusy = false;
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -22,7 +22,7 @@ else if (ReadOnly)
} }
else else
{ {
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Imported fields appear in a table and can be edited.</MudText> <MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Additional fields appear in a table and can be edited.</MudText>
<MarkdownEditor Value="@_content" <MarkdownEditor Value="@_content"
ValueChanged="@((string? value) => _content = value ?? string.Empty)" ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..." Placeholder="Student notes..."
@@ -95,7 +95,7 @@
<MudPaper Class="pa-6 mb-4"> <MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText> <MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
<MudText Typo="Typo.body2" Class="mb-3"> <MudText Typo="Typo.body2" Class="mb-3">
Field names from each student's imported notes table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line). Field names from each student's additional-fields table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line).
</MudText> </MudText>
@if (_availableFields.Count > 0) @if (_availableFields.Count > 0)
{ {
@@ -117,11 +117,11 @@
else else
{ {
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-3"> <MudText Typo="Typo.caption" Class="mud-text-secondary mb-3">
No imported fields found in student notes yet. No additional fields found in student notes yet.
</MudText> </MudText>
} }
<MudTextField @bind-Value="_noteFieldsText" <MudTextField @bind-Value="_noteFieldsText"
Label="Imported field columns" Label="Additional field columns"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Lines="5" Lines="5"
HelperText="Example: Interview Time" /> HelperText="Example: Interview Time" />
@@ -80,7 +80,7 @@
{ {
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList(); _history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
} }
catch (Exception ex) catch (Exception)
{ {
// Error handling - could show snackbar if we had access // Error handling - could show snackbar if we had access
} }
+8 -7
View File
@@ -59,7 +59,7 @@
var isExpanded = GetNoteExpanded(noteId); var isExpanded = GetNoteExpanded(noteId);
<MudExpansionPanel @key="@($"note-{noteId}")" <MudExpansionPanel @key="@($"note-{noteId}")"
Icon="@Icons.Material.Filled.Note" Icon="@Icons.Material.Filled.Note"
IsExpanded="@isExpanded" Expanded="@isExpanded"
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))" ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
Class="@MarkdownHelper.GetNoteColorClass(noteId)"> Class="@MarkdownHelper.GetNoteColorClass(noteId)">
<TitleContent> <TitleContent>
@@ -71,15 +71,16 @@
</MudText> </MudText>
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded) @if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
{ {
<div @onclick:stopPropagation="true" class="flex-shrink-0">
<MudTooltip Text="@(note.IsPinned ? "Unpin note" : "Pin note")">
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)" <MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)" OnClick="() => TogglePin(note)"
OnClick:StopPropagation="true"
Variant="Variant.Text" Variant="Variant.Text"
Size="Size.Small" Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)" Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))" Disabled="@(IsPinDisabled(note))" />
Title="@(note.IsPinned ? "Unpin note" : "Pin note")" </MudTooltip>
Class="flex-shrink-0" /> </div>
} }
</MudStack> </MudStack>
</TitleContent> </TitleContent>
@@ -301,7 +302,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions()); var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled && !_isDisposed) if (result is { Canceled: false } && !_isDisposed)
{ {
await LoadNotes(); await LoadNotes();
} }
@@ -327,7 +328,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options); var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
var result = await dialog.Result; var result = await dialog.Result;
if (!result.Canceled && !_isDisposed) if (result is { Canceled: false } && !_isDisposed)
{ {
await LoadNotes(); await LoadNotes();
} }
@@ -3,28 +3,26 @@
@inject IDialogService DialogService @inject IDialogService DialogService
@implements IAsyncDisposable @implements IAsyncDisposable
<div @ref="_anchorElement" <div @onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
@onmouseleave="@(() => _popoverOpen = false)" @onmouseleave="@(() => _popoverOpen = false)"
style="display: inline-block;"> style="display: inline-block; position: relative;">
<MudTooltip Text="@TooltipText">
<MudButton StartIcon="@IconValue" <MudButton StartIcon="@IconValue"
OnClick="OpenDialog" OnClick="OpenDialog"
Variant="@Variant" Variant="@Variant"
Size="@Size" Size="@Size"
Color="@ButtonColor" Color="@ButtonColor"
Tooltip="@TooltipText"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)"> Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@if (!string.IsNullOrEmpty(ButtonText)) @if (!string.IsNullOrEmpty(ButtonText))
{ {
@ButtonText @ButtonText
} }
</MudButton> </MudButton>
</div> </MudTooltip>
<MudPopover @bind-Open="_popoverOpen" <MudPopover Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter" AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter" TransformOrigin="Origin.TopCenter"
Elevation="8" Elevation="8">
Anchor="@_anchorElement">
<ChildContent> <ChildContent>
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent)) @if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
{ {
@@ -41,7 +39,8 @@
</MudPaper> </MudPaper>
} }
</ChildContent> </ChildContent>
</MudPopover> </MudPopover>
</div>
@code { @code {
[Parameter] [Parameter]
@@ -68,7 +67,6 @@
private int _noteId = 0; private int _noteId = 0;
private string _noteContent = string.Empty; private string _noteContent = string.Empty;
private bool _popoverOpen = false; private bool _popoverOpen = false;
private ElementReference _anchorElement;
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default); private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false; private bool _isDisposed = false;
@@ -30,6 +30,7 @@
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false"> <MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink> <MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
<MudNavLink Href="/print" Icon="@Icons.Material.Filled.Print">Page printer</MudNavLink>
</MudNavGroup> </MudNavGroup>
<AuthorizeView Roles="Administrator"> <AuthorizeView Roles="Administrator">
+1 -1
View File
@@ -55,7 +55,7 @@ public class ChapterSettings
public SchoolLevel? SchoolLevel { get; set; } public SchoolLevel? SchoolLevel { get; set; }
/// <summary> /// <summary>
/// Field names from student note <c>## Imported fields</c> tables to show as Students index columns. /// Field names from student note <c>## Additional fields</c> tables to show as Students index columns.
/// </summary> /// </summary>
public List<string> StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns]; public List<string> StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns];
+2
View File
@@ -213,6 +213,8 @@ builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>(); builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>(); builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>(); builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
builder.Services.AddScoped<WebApp.Services.INotePrintService, WebApp.Services.NotePrintService>();
builder.Services.AddScoped<WebApp.Services.IPrintPresetService, WebApp.Services.PrintPresetService>();
builder.Services.Configure<StateScheduleHandoutOptions>( builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
+28
View File
@@ -0,0 +1,28 @@
using Core.Printing;
namespace WebApp.Services;
public class NotePrintRequest
{
public required PrintEntityType EntityType { get; init; }
public required string TemplateMarkdown { get; init; }
public required PrintPresetFilters Filters { get; init; }
public required IReadOnlyList<string> ImportedFieldCatalog { get; init; }
}
public class NotePrintPage
{
public required string DisplayName { get; init; }
public required string Html { get; init; }
}
public interface INotePrintService
{
Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
NotePrintRequest request,
CancellationToken cancellationToken = default);
}
+1 -1
View File
@@ -38,7 +38,7 @@ public interface INotesService
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default); Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Distinct Field names from student note <c>## Imported fields</c> tables. /// Distinct Field names from student note <c>## Additional fields</c> tables.
/// </summary> /// </summary>
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default); Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
+18
View File
@@ -0,0 +1,18 @@
using Core.Entities;
namespace WebApp.Services;
public interface IPrintPresetService
{
Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default);
Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default);
Task<bool> NameExistsAsync(string name, int? excludeId = null, CancellationToken cancellationToken = default);
Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
+247
View File
@@ -0,0 +1,247 @@
using Core.Entities;
using Core.Notes;
using Core.Printing;
using Data;
using Microsoft.EntityFrameworkCore;
using WebApp.Models;
namespace WebApp.Services;
public class NotePrintService : INotePrintService
{
private readonly AppDbContext _context;
private readonly IConfiguration _configuration;
private readonly INotesService _notesService;
public NotePrintService(
AppDbContext context,
IConfiguration configuration,
INotesService notesService)
{
_context = context;
_configuration = configuration;
_notesService = notesService;
}
public async Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
NotePrintRequest request,
CancellationToken cancellationToken = default)
{
var chapter = ChapterTokens();
var template = request.TemplateMarkdown;
return request.EntityType switch
{
PrintEntityType.Student => await PreviewStudentsAsync(request, chapter, template, cancellationToken),
PrintEntityType.Team => await PreviewTeamsAsync(request, chapter, template, cancellationToken),
PrintEntityType.Event => await PreviewEventsAsync(request, chapter, template, cancellationToken),
_ => []
};
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewStudentsAsync(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Students.AsNoTracking().AsQueryable();
if (filters.Grade.HasValue)
query = query.Where(s => s.Grade == filters.Grade.Value);
if (filters.TsaYear.HasValue)
query = query.Where(s => s.TsaYear == filters.TsaYear.Value);
if (filters.IsOfficer == true)
query = query.Where(s => s.OfficerRole != null);
else if (filters.IsOfficer == false)
query = query.Where(s => s.OfficerRole == null);
var students = await query
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(cancellationToken);
var notes = await _notesService.GetStudentNotesAsync(students.Select(s => s.Id));
List<(Student Student, Dictionary<string, string?> Imported)> rows = [];
foreach (var student in students)
{
notes.TryGetValue(student.Id, out var note);
var parsed = ImportedFieldsTable.ParseFields(note?.Content);
rows.Add((student, ImportedTokens(parsed, request.ImportedFieldCatalog)));
}
return
[
.. rows.Select(row => MergePage(
row.Student.LastNameFirstName,
template,
row.Imported,
StudentTokens(row.Student),
chapter))
];
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewTeamsAsync(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Teams
.AsNoTracking()
.Include(t => t.Event)
.Include(t => t.Captain)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(filters.TeamIdentifierContains))
{
var term = filters.TeamIdentifierContains.Trim();
query = query.Where(t => t.Identifier != null && t.Identifier.Contains(term));
}
var teams = await query
.OrderBy(t => t.Event.Name)
.ThenBy(t => t.Identifier)
.ToListAsync(cancellationToken);
return
[
.. teams.Select(team => MergePage(team.ToString(), template, null, TeamTokens(team), chapter))
];
}
private async Task<IReadOnlyList<NotePrintPage>> PreviewEventsAsync(
NotePrintRequest request,
Dictionary<string, string?> chapter,
string template,
CancellationToken cancellationToken)
{
var filters = request.Filters;
var query = _context.Events.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(filters.EventNameContains))
{
var term = filters.EventNameContains.Trim();
query = query.Where(e => e.Name.Contains(term));
}
if (filters.EventFormat.HasValue)
query = query.Where(e => e.EventFormat == filters.EventFormat.Value);
if (filters.RegionalOnly == true)
query = query.Where(e => e.ChapterEligibilityCountRegionals > 0);
else if (filters.RegionalOnly == false)
query = query.Where(e => e.ChapterEligibilityCountRegionals <= 0);
var events = await query
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
return
[
.. events.Select(evt => MergePage(evt.Name, template, null, EventTokens(evt), chapter))
];
}
private static NotePrintPage MergePage(
string displayName,
string template,
Dictionary<string, string?>? imported,
Dictionary<string, string?> entity,
Dictionary<string, string?> chapter)
{
var map = PrintTokenMap.Build(imported, entity, chapter);
return new NotePrintPage
{
DisplayName = displayName,
Html = NoteTemplateMerger.ApplyLayout(MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)))
};
}
private Dictionary<string, string?> ChapterTokens()
{
var settings = ChapterSettings.FromConfiguration(_configuration);
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["Chapter.Name"] = settings.Name,
["Chapter.ShortName"] = settings.ShortName,
["Chapter.CompetitionYear"] = settings.CompetitionYear,
["Chapter.YearlyTheme"] = settings.YearlyTheme,
["Chapter.StateAbbrev"] = settings.StateAbbrev
};
}
private static Dictionary<string, string?> StudentTokens(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
{
["FirstName"] = student.FirstName,
["LastName"] = student.LastName,
["Name"] = student.Name,
["LastNameFirstName"] = student.LastNameFirstName,
["Grade"] = student.Grade.ToString(),
["TsaYear"] = student.TsaYear.ToString(),
["Email"] = student.Email,
["PhoneNumber"] = student.PhoneNumber,
["StateId"] = student.StateId,
["RegionalId"] = student.RegionalId,
["NationalId"] = student.NationalId,
["OfficerRole"] = student.OfficerRole?.ToString()
};
private static Dictionary<string, string?> TeamTokens(Team team)
{
var evt = team.Event;
var tokens = EventSharedTokens(evt);
tokens["Identifier"] = team.Identifier;
tokens["Name"] = team.ToString();
tokens["EventName"] = evt?.Name;
tokens["EventShortName"] = evt?.ShortName;
return tokens;
}
private static Dictionary<string, string?> EventTokens(EventDefinition evt)
{
var tokens = EventSharedTokens(evt);
tokens["Name"] = evt.Name;
tokens["ShortName"] = evt.ShortName;
tokens["LevelOfEffort"] = evt.LevelOfEffort?.ToString();
tokens["SemifinalistActivity"] = evt.SemifinalistActivity;
tokens["RegionalEvent"] = evt.RegionalEvent ? "Yes" : "No";
tokens["Documentation"] = evt.Documentation;
tokens["Notes"] = evt.Notes;
return tokens;
}
private static Dictionary<string, string?> EventSharedTokens(EventDefinition? evt) =>
new(StringComparer.OrdinalIgnoreCase)
{
["EventFormat"] = evt?.EventFormat.ToString(),
["TeamSize"] = evt?.TeamSize,
["Eligibility"] = evt?.Eligibility,
["Description"] = evt?.Description,
["Theme"] = evt?.Theme
};
private static Dictionary<string, string?> ImportedTokens(
IReadOnlyList<ImportedField> parsed,
IReadOnlyList<string> catalog)
{
var imported = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var name in catalog)
{
if (string.IsNullOrWhiteSpace(name))
continue;
imported[name] = parsed
.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
?.Value;
}
foreach (var field in parsed)
imported.TryAdd(field.Name, field.Value);
return imported;
}
}
+97
View File
@@ -0,0 +1,97 @@
using Core.Entities;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
public class PrintPresetService : IPrintPresetService
{
private readonly AppDbContext _context;
private readonly ILogger<PrintPresetService> _logger;
public PrintPresetService(AppDbContext context, ILogger<PrintPresetService> logger)
{
_context = context;
_logger = logger;
}
public async Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.PrintPresets
.AsNoTracking()
.Include(p => p.Note)
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
public async Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.PrintPresets
.AsNoTracking()
.Include(p => p.Note)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<bool> NameExistsAsync(
string name,
int? excludeId = null,
CancellationToken cancellationToken = default)
{
var trimmed = name.Trim();
var query = _context.PrintPresets.AsNoTracking().Where(p => p.Name == trimmed);
if (excludeId.HasValue)
query = query.Where(p => p.Id != excludeId.Value);
return await query.AnyAsync(cancellationToken);
}
public async Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
{
preset.Name = preset.Name.Trim();
preset.UpdatedAt = DateTime.UtcNow;
preset.Note = null!;
if (await NameExistsAsync(preset.Name, null, cancellationToken))
throw new InvalidOperationException($"A print preset named '{preset.Name}' already exists.");
_context.PrintPresets.Add(preset);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset created: {PresetId} {Name}", preset.Id, preset.Name);
return preset;
}
public async Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
{
var existing = await _context.PrintPresets
.FirstOrDefaultAsync(p => p.Id == preset.Id, cancellationToken);
if (existing is null)
throw new InvalidOperationException($"Print preset {preset.Id} was not found.");
var name = preset.Name.Trim();
if (await NameExistsAsync(name, preset.Id, cancellationToken))
throw new InvalidOperationException($"A print preset named '{name}' already exists.");
existing.Name = name;
existing.NoteId = preset.NoteId;
existing.EntityType = preset.EntityType;
existing.FiltersJson = preset.FiltersJson;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset updated: {PresetId} {Name}", existing.Id, existing.Name);
return existing;
}
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var existing = await _context.PrintPresets
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
if (existing is null)
return;
_context.PrintPresets.Remove(existing);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset deleted: {PresetId} {Name}", id, existing.Name);
}
}
@@ -4,7 +4,7 @@ using Core.Services;
namespace WebApp.Services; namespace WebApp.Services;
/// <summary> /// <summary>
/// Creates or updates #Student:{id} notes when imported fields actually change. /// Creates or updates #Student:{id} notes when additional fields actually change.
/// </summary> /// </summary>
public class StudentNotesImportSaveService : IStudentNotesImportSaveService public class StudentNotesImportSaveService : IStudentNotesImportSaveService
{ {
+78
View File
@@ -23,6 +23,52 @@
.pagebreak { .pagebreak {
page-break-after: always; page-break-after: always;
} }
.markdown-content,
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content p,
.markdown-content li,
.markdown-content td,
.markdown-content th {
color: #000;
background-color: transparent;
}
.markdown-content h1,
.markdown-content h2 {
border-bottom-color: #ccc;
}
.markdown-content table th,
.markdown-content table td {
border-color: #000;
}
.markdown-content table th {
background-color: #eee;
}
.markdown-content a {
color: #000;
text-decoration: underline;
}
.note-print-page,
.note-print-page .markdown-content {
font-size: var(--print-font-size, 12pt);
}
.print-answer-space {
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 1.35em,
#999 1.35em,
#999 calc(1.35em + 1px)
);
}
} }
.ranked-event-column > div:only-child{ .ranked-event-column > div:only-child{
@@ -324,6 +370,38 @@
height: auto; height: auto;
} }
.note-print-page {
--print-font-size: 12pt;
--print-answer-lines: 3;
}
.note-print-page .markdown-content {
font-size: var(--print-font-size, 12pt);
line-height: 1.35;
}
.note-print-page .markdown-content h1 {
font-size: 1.3em;
margin-top: 0;
}
.note-print-page .markdown-content table {
margin: 0.5em 0 1em;
font-size: 0.85em;
}
.print-answer-space {
min-height: calc(var(--print-answer-lines, 3) * 1.35em);
margin: 0.2em 0 0.75em;
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 1.35em,
#bbb 1.35em,
#bbb calc(1.35em + 1px)
);
}
/* Note color classes - pastel background colors */ /* Note color classes - pastel background colors */
.note-color-0 { .note-color-0 {
background-color: #e3f2fd; background-color: #e3f2fd;
+59
View File
@@ -0,0 +1,59 @@
# Page printer
**Created:** 2026-08-29
**Last updated:** 2026-08-30
**Description:** Merge a markdown note onto students, teams, or events and print one page per match. Save the recipe as a print preset.
## Where to open it
Sign in and go to **Tools → Page printer** (`/print`).
## Write a template note
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.
```markdown
# Interview — {{FirstName}} {{LastName}}
| Grade | Interview Time | Application |
| --- | --- | --- |
| {{Grade}} | {{Interview Time}} | {{Application}} |
1. Why did you join TSA?
2. What events interest you?
```
Unknown tokens stay visible so typos are obvious. Empty values (including a missing Interview Time) print blank.
Put `{{PageBreak}}` on its own line to force a new printed sheet **inside** one record (for example, questions on page 1 and a scoring rubric on page 2).
Put `{{AnswerSpace}}` where you want ruled write-in lines. Markdown collapses blank lines, so extra empty lines in the note will not leave room to write.
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).
## Filters
Pick **Students**, **Teams**, or **Events**, then optional filters.
For students: Grade, TSA year, and officer (any / officers only / non-officers). Example: TSA year `1` prints first-year students. `{{OfficerRole}}` still prints the office name when they have one. Additional fields such as Interview Time are merge tokens, not filters. Student pages sort by last name, then first name.
## 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.
1. Set the note, entity, and filters.
2. Enter a name and click **Save as**.
3. Later, choose a preset, click **Preview**, then **Print**.
4. **Update** overwrites the selected preset. **Delete** removes it.
If the template note was removed, the filters still load. Choose another note before Preview.
## Print
**Preview** builds the pages. **Print** opens the browser print dialog (same as other handouts). Navigation is 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.
**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.
+4 -4
View File
@@ -1,7 +1,7 @@
# Import Students and Note Fields # Import Students and Note Fields
**Created:** 2026-08-29 **Created:** 2026-08-29
**Last updated:** 2026-08-29 **Last updated:** 2026-08-30
**Description:** How `/students/import` creates students and merges leftover CSV columns into each student's markdown notes. **Description:** How `/students/import` creates students and merges leftover CSV columns into each student's markdown notes.
@@ -9,7 +9,7 @@
Each student has a system note titled `#Student:{id}`. It is edited on the student edit page and shown on student details. Those notes are hidden from the main Notes list. Each student has a system note titled `#Student:{id}`. It is edited on the student edit page and shown on student details. Those notes are hidden from the main Notes list.
Imported leftover values go in a table under `## Imported fields`. Other markdown above or below that heading is left alone. Leftover CSV values (and any fields you add by hand) go in a table under `## Additional fields`. Any `## … fields` heading is read the same way (for example an older `## Imported fields`). Other markdown above or below that heading is left alone.
## CSV format ## CSV format
@@ -39,7 +39,7 @@ A notes-only file without `Grade` will not import. Put roster and leftover colum
1. Sign in as an Administrator. 1. Sign in as an Administrator.
2. Open **Students** and click **Import**, or go to `/students/import`. 2. Open **Students** and click **Import**, or go to `/students/import`.
The arrow next to **Import** downloads a CSV template (roster columns, Students index columns, and any other imported fields already in notes). The arrow next to **Import** downloads a CSV template (roster columns, Students index columns, and any other additional fields already in notes).
`/import` still opens this same student page. `/import` still opens this same student page.
3. Upload the student CSV. 3. Upload the student CSV.
4. Review new vs existing counts and leftover field names. 4. Review new vs existing counts and leftover field names.
@@ -47,4 +47,4 @@ A notes-only file without `Grade` will not import. Put roster and leftover colum
## Index columns ## Index columns
Chapter Settings → **Student Index Columns** lists which imported field names appear as extra columns on the Students index. The page also shows field names already present in student notes; click a chip to add or remove it from the list. Open Students again after saving; a restart is not required. Chapter Settings → **Student Index Columns** lists which additional field names appear as extra columns on the Students index. The page also shows field names already present in student notes; click a chip to add or remove it from the list. Open Students again after saving; a restart is not required.
+14
View File
@@ -0,0 +1,14 @@
# Page printer plan
**Created:** 2026-08-29
**Last updated:** 2026-08-30
**Description:** Implementation notes for the Tools page printer (markdown note merge + print presets).
User-facing steps: [docs/instructions/page-printer.md](../instructions/page-printer.md).
## Built
- Core merge: `{{tokens}}`, `{{PageBreak}}`, `{{AnswerSpace}}`, print presets JSON
- `/print` UI: entity filters, font size, answer-space lines, Preview/Print, save/load/update/delete presets
- Student pages sort by last name, then first name
- EF migration `AddPrintPresets` (applied on next app start)