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:
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Core.Printing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity a print preset merges a note onto.
|
||||||
|
/// </summary>
|
||||||
|
public enum PrintEntityType
|
||||||
|
{
|
||||||
|
Student,
|
||||||
|
Team,
|
||||||
|
Event
|
||||||
|
}
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("<", "<", StringComparison.Ordinal)
|
||||||
|
.Replace(">", ">", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<"));
|
||||||
|
Assert.That(escaped, Does.Contain(">"));
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user