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>
public static class ImportedFieldsTable
{
public const string Heading = "## Imported fields";
public const string Heading = "## Additional fields";
public static string NormalizeValue(string? raw)
{
@@ -18,7 +18,7 @@ public static class ImportedFieldsTable
}
/// <summary>
/// Reads Field/Value rows from the Imported fields section.
/// Reads Field/Value rows from the Additional fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
@@ -50,7 +50,7 @@ public static class ImportedFieldsTable
}
/// <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.
/// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
@@ -126,7 +126,7 @@ public static class ImportedFieldsTable
}
/// <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>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
@@ -150,11 +150,10 @@ public static class ImportedFieldsTable
if (string.IsNullOrEmpty(markdown))
return null;
var start = IndexOfHeading(markdown);
if (start < 0)
if (!TryFindHeading(markdown, out var start, out var headingLength))
return null;
var afterHeading = start + Heading.Length;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
@@ -164,8 +163,7 @@ public static class ImportedFieldsTable
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
var start = IndexOfHeading(existingMarkdown);
if (start < 0)
if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
@@ -173,7 +171,7 @@ public static class ImportedFieldsTable
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + Heading.Length;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
@@ -199,8 +197,45 @@ public static class ImportedFieldsTable
return builder.ToString();
}
private static int IndexOfHeading(string markdown) =>
markdown.IndexOf(Heading, StringComparison.Ordinal);
/// <summary>
/// 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)
{
+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<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public DbSet<PrintPreset> PrintPresets { get; set; }
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");
});
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 =>
{
b.Property<int>("Id")
@@ -467,6 +503,17 @@ namespace Data.Migrations
b.Navigation("Note");
});
modelBuilder.Entity("Core.Entities.PrintPreset", b =>
{
b.HasOne("Core.Entities.Note", "Note")
.WithMany()
.HasForeignKey("NoteId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Note");
});
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
+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 == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
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]
+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 result = await dialog.Result;
if (!result.Canceled)
if (result is { Canceled: false })
{
// Refresh data if meeting was updated or deleted
await RefreshMeetingHistories();
@@ -664,7 +664,7 @@
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);
}
@@ -401,7 +401,7 @@
var result = await dialog.Result;
// Refresh meeting history if dialog was saved
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadMeetingHistory();
}
@@ -11,12 +11,11 @@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
Style="cursor: pointer;">
</MudIcon>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
aria-label="Restore team" />
@{
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
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
{
<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"
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..."
@@ -95,7 +95,7 @@
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
<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>
@if (_availableFields.Count > 0)
{
@@ -117,11 +117,11 @@
else
{
<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>
}
<MudTextField @bind-Value="_noteFieldsText"
Label="Imported field columns"
Label="Additional field columns"
Variant="Variant.Outlined"
Lines="5"
HelperText="Example: Interview Time" />
@@ -80,7 +80,7 @@
{
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
}
catch (Exception ex)
catch (Exception)
{
// Error handling - could show snackbar if we had access
}
+13 -12
View File
@@ -59,7 +59,7 @@
var isExpanded = GetNoteExpanded(noteId);
<MudExpansionPanel @key="@($"note-{noteId}")"
Icon="@Icons.Material.Filled.Note"
IsExpanded="@isExpanded"
Expanded="@isExpanded"
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
<TitleContent>
@@ -71,15 +71,16 @@
</MudText>
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
{
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
OnClick:StopPropagation="true"
Variant="Variant.Text"
Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))"
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
Class="flex-shrink-0" />
<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)"
OnClick="() => TogglePin(note)"
Variant="Variant.Text"
Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))" />
</MudTooltip>
</div>
}
</MudStack>
</TitleContent>
@@ -301,7 +302,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadNotes();
}
@@ -327,7 +328,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadNotes();
}
@@ -3,28 +3,26 @@
@inject IDialogService DialogService
@implements IAsyncDisposable
<div @ref="_anchorElement"
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
<div @onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
@onmouseleave="@(() => _popoverOpen = false)"
style="display: inline-block;">
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
Size="@Size"
Color="@ButtonColor"
Tooltip="@TooltipText"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@if (!string.IsNullOrEmpty(ButtonText))
{
@ButtonText
}
</MudButton>
</div>
<MudPopover @bind-Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter"
Elevation="8"
Anchor="@_anchorElement">
style="display: inline-block; position: relative;">
<MudTooltip Text="@TooltipText">
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
Size="@Size"
Color="@ButtonColor"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@if (!string.IsNullOrEmpty(ButtonText))
{
@ButtonText
}
</MudButton>
</MudTooltip>
<MudPopover Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter"
Elevation="8">
<ChildContent>
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
{
@@ -41,7 +39,8 @@
</MudPaper>
}
</ChildContent>
</MudPopover>
</MudPopover>
</div>
@code {
[Parameter]
@@ -68,7 +67,6 @@
private int _noteId = 0;
private string _noteContent = string.Empty;
private bool _popoverOpen = false;
private ElementReference _anchorElement;
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
@@ -30,6 +30,7 @@
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
<MudNavLink Href="/print" Icon="@Icons.Material.Filled.Print">Page printer</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Administrator">
+1 -1
View File
@@ -55,7 +55,7 @@ public class ChapterSettings
public SchoolLevel? SchoolLevel { get; set; }
/// <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>
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<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
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.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);
/// <summary>
/// Distinct Field names from student note <c>## Imported fields</c> tables.
/// Distinct Field names from student note <c>## Additional fields</c> tables.
/// </summary>
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;
/// <summary>
/// Creates or updates #Student:{id} notes when imported fields actually change.
/// Creates or updates #Student:{id} notes when additional fields actually change.
/// </summary>
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
{
+79 -1
View File
@@ -22,7 +22,53 @@
.pagebreak {
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{
@@ -324,6 +370,38 @@
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-0 {
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
**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.
@@ -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.
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
@@ -39,7 +39,7 @@ A notes-only file without `Grade` will not import. Put roster and leftover colum
1. Sign in as an Administrator.
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.
3. Upload the student CSV.
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
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)