Compare commits

...
5 Commits
Author SHA1 Message Date
poprhythmandCursor c03ffc0833 feat: edit print templates on the printer page and store them on presets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 22:45:56 -04:00
poprhythmandCursor 1337d9833d feat: stitch page-printer tables into one roster and add chapter team-count tokens
When New page per record is off, a single-table template shares one header across records. Event and team pages can print regional/state team counts and nationals eligibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 21:37:49 -04:00
poprhythmandCursor 3712dba974 feat: merge event rankings and attribute badges into the page printer
Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 23:03:10 -04:00
poprhythmandCursor 3f50d6e635 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>
2026-08-30 15:38:43 -04:00
poprhythmandCursor 4cfd85b902 feat: import leftover student fields into notes and show them on the roster
Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 23:55:03 -04:00
87 changed files with 6401 additions and 382 deletions
+24
View File
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Core.Printing;
namespace Core.Entities;
/// <summary>
/// Saved page-printer recipe: template markdown, 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 string TemplateMarkdown { get; set; } = string.Empty;
public PrintEntityType EntityType { get; set; }
public string FiltersJson { get; set; } = "{}";
public DateTime UpdatedAt { get; set; }
}
+48
View File
@@ -0,0 +1,48 @@
using Core.Entities;
using Core.Notes;
namespace Core.Models;
/// <summary>
/// Result of parsing a student notes field CSV.
/// </summary>
public class StudentNotesImportResult
{
public List<StudentNotesImportMatch> Matches { get; set; } = [];
public List<StudentNotesImportIssue> Issues { get; set; } = [];
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
public IReadOnlyList<string> FieldNames { get; set; } = [];
public bool IsSuccess => Errors.Count == 0;
public int StudentsWithChanges => Matches.Count(m => m.Merge.Changed);
}
public class StudentNotesImportMatch
{
public required Student Student { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public List<ImportedField> IncomingFields { get; set; } = [];
public required ImportedFieldsMergeResult Merge { get; set; }
}
public class StudentNotesImportIssue
{
public int RowNumber { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
+307
View File
@@ -0,0 +1,307 @@
using System.Text;
namespace Core.Notes;
/// <summary>
/// Parses and upserts a generic Field/Value markdown table under a stable heading.
/// </summary>
public static class ImportedFieldsTable
{
public const string Heading = "## Additional fields";
public static string NormalizeValue(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Equals("x", StringComparison.OrdinalIgnoreCase))
return "Yes";
return value;
}
/// <summary>
/// Reads Field/Value rows from the Additional fields section.
/// </summary>
public static List<ImportedField> ParseFields(string? markdown)
{
var section = ExtractSection(markdown);
if (string.IsNullOrWhiteSpace(section))
return [];
List<ImportedField> fields = [];
foreach (var rawLine in section.Split('\n'))
{
var line = rawLine.Trim();
if (!line.StartsWith('|') || line.Contains("---", StringComparison.Ordinal))
continue;
var cells = SplitTableCells(line);
if (cells.Length < 2)
continue;
var field = UnescapeCell(cells[0]);
var value = UnescapeCell(cells[1]);
if (field.Equals("Field", StringComparison.OrdinalIgnoreCase)
&& value.Equals("Value", StringComparison.OrdinalIgnoreCase))
continue;
fields.Add(new ImportedField(field, value));
}
return fields;
}
/// <summary>
/// Upserts incoming fields into the Additional fields section. Incoming values win.
/// Fields not in <paramref name="incoming"/> are kept. Identical values are not changes.
/// </summary>
public static ImportedFieldsMergeResult Merge(string? existingMarkdown, IReadOnlyList<ImportedField> incoming)
{
var existing = ParseFields(existingMarkdown);
var merged = existing.ToList();
List<ImportedFieldChange> changes = [];
foreach (var incomingField in incoming)
{
var name = incomingField.Name.Trim();
if (string.IsNullOrEmpty(name))
continue;
var value = NormalizeValue(incomingField.Value);
var index = merged.FindIndex(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
merged.Add(new ImportedField(name, value));
changes.Add(new ImportedFieldChange
{
Field = name,
PreviousValue = null,
NewValue = value,
IsNew = true
});
continue;
}
var current = merged[index];
if (string.Equals(current.Value, value, StringComparison.Ordinal))
continue;
merged[index] = new ImportedField(current.Name, value);
changes.Add(new ImportedFieldChange
{
Field = current.Name,
PreviousValue = current.Value,
NewValue = value,
IsNew = false
});
}
var section = FormatSection(merged);
var markdown = ReplaceSection(existingMarkdown, section);
return new ImportedFieldsMergeResult
{
Markdown = markdown,
Changed = changes.Count > 0,
Changes = changes
};
}
public static string FormatSection(IReadOnlyList<ImportedField> fields)
{
var builder = new StringBuilder();
builder.AppendLine(Heading);
builder.AppendLine();
builder.AppendLine("| Field | Value |");
builder.AppendLine("| --- | --- |");
foreach (var field in fields)
builder.AppendLine($"| {EscapeCell(field.Name)} | {EscapeCell(field.Value)} |");
return builder.ToString().TrimEnd() + Environment.NewLine;
}
public static string? GetFieldValue(string? markdown, string fieldName)
{
var field = ParseFields(markdown)
.FirstOrDefault(f => f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
return field?.Value;
}
/// <summary>
/// Unique additional-field names across notes, first-seen casing, sorted A–Z.
/// </summary>
public static List<string> DistinctFieldNames(IEnumerable<string?> markdowns)
{
Dictionary<string, string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (var markdown in markdowns)
{
foreach (var field in ParseFields(markdown))
{
var name = field.Name.Trim();
if (name.Length == 0)
continue;
names.TryAdd(name, name);
}
}
return [.. names.Values.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
}
private static string? ExtractSection(string? markdown)
{
if (string.IsNullOrEmpty(markdown))
return null;
if (!TryFindHeading(markdown, out var start, out var headingLength))
return null;
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(markdown, afterHeading);
return nextHeading < 0 ? markdown[start..] : markdown[start..nextHeading];
}
private static string ReplaceSection(string? existingMarkdown, string section)
{
if (string.IsNullOrWhiteSpace(existingMarkdown))
return section.TrimEnd() + Environment.NewLine;
if (!TryFindHeading(existingMarkdown, out var start, out var headingLength))
{
var prefix = existingMarkdown.TrimEnd();
return string.IsNullOrEmpty(prefix)
? section
: prefix + Environment.NewLine + Environment.NewLine + section;
}
var afterHeading = start + headingLength;
var nextHeading = FindNextHeading(existingMarkdown, afterHeading);
var before = existingMarkdown[..start].TrimEnd();
var after = nextHeading < 0 ? string.Empty : existingMarkdown[nextHeading..].TrimStart();
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(before))
{
builder.Append(before);
builder.AppendLine();
builder.AppendLine();
}
builder.Append(section.TrimEnd());
builder.AppendLine();
if (!string.IsNullOrEmpty(after))
{
builder.AppendLine();
builder.Append(after.TrimEnd());
builder.AppendLine();
}
return builder.ToString();
}
/// <summary>
/// First <c>## … fields</c> heading (any prefix). New sections are written as <see cref="Heading"/>.
/// </summary>
private static bool TryFindHeading(string markdown, out int start, out int headingLength)
{
var index = 0;
while (index < markdown.Length)
{
var lineEnd = markdown.IndexOf('\n', index);
var end = lineEnd < 0 ? markdown.Length : lineEnd;
var line = markdown[index..end].TrimEnd('\r');
if (IsFieldsHeading(line))
{
start = index;
headingLength = line.Length;
return true;
}
if (lineEnd < 0)
break;
index = lineEnd + 1;
}
start = -1;
headingLength = 0;
return false;
}
private static bool IsFieldsHeading(string line)
{
var trimmed = line.Trim();
if (!trimmed.StartsWith("## ", StringComparison.Ordinal)
|| trimmed.StartsWith("###", StringComparison.Ordinal))
return false;
var title = trimmed[3..].Trim();
return title.EndsWith(" fields", StringComparison.OrdinalIgnoreCase)
|| title.Equals("fields", StringComparison.OrdinalIgnoreCase);
}
private static int FindNextHeading(string markdown, int startIndex)
{
var index = startIndex;
while (index < markdown.Length)
{
var lineStart = markdown.IndexOf('\n', index);
if (lineStart < 0)
return -1;
lineStart++;
if (lineStart < markdown.Length && markdown[lineStart] == '#' &&
lineStart + 2 < markdown.Length && markdown[lineStart + 1] == '#' &&
markdown[lineStart + 2] == ' ')
return lineStart;
index = lineStart;
}
return -1;
}
/// <summary>
/// Splits a markdown table row into cells. Leading/trailing pipes are ignored so a blank
/// value (e.g. <c>| Teacher Rec 3 | |</c>) is kept instead of dropped.
/// </summary>
private static string[] SplitTableCells(string line)
{
var parts = line.Split('|', StringSplitOptions.TrimEntries);
var start = 0;
var length = parts.Length;
if (length > 0 && parts[0].Length == 0)
{
start = 1;
length--;
}
if (length > 0 && parts[start + length - 1].Length == 0)
length--;
return length <= 0 ? [] : parts[start..(start + length)];
}
private static string EscapeCell(string value) => value.Replace("|", "\\|");
private static string UnescapeCell(string value) => value.Replace("\\|", "|");
}
public record ImportedField(string Name, string Value);
public class ImportedFieldsMergeResult
{
public required string Markdown { get; init; }
public bool Changed { get; init; }
public List<ImportedFieldChange> Changes { get; init; } = [];
}
public class ImportedFieldChange
{
public required string Field { get; init; }
public string? PreviousValue { get; init; }
public required string NewValue { get; init; }
public bool IsNew { get; init; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace Core.Notes;
/// <summary>
/// Default imported-field names used for Students index columns and the CSV template.
/// </summary>
public static class StudentNoteFieldDefaults
{
public static readonly string[] IndexColumns =
[
"Interview Time",
"Application",
"Club Permission Slip"
];
}
+29
View File
@@ -0,0 +1,29 @@
using Core.Entities;
using FuzzySharp;
namespace Core.Parsers;
/// <summary>
/// Fuzzy-matches a CSV or pasted name to existing students.
/// </summary>
public static class FuzzyStudentMatcher
{
public const int MatchThreshold = 90;
public static (Student Student, int Score)? Find(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: Score(s, name)))
.Where(x => x.Score >= MatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
public static int Score(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
}
+1 -19
View File
@@ -6,7 +6,6 @@ namespace Core.Parsers;
public class StudentEventRankingParser : CsvParserBase
{
public const int StudentMatchThreshold = 90;
public const int EventMatchThreshold = 70;
public const int EventAmbiguityGap = 8;
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FindStudent(students, name);
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentEventRankingIssue
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
return result;
}
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
.Where(x => x.Score >= StudentMatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
private static int ScoreStudent(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
{
var scored = events
+63
View File
@@ -0,0 +1,63 @@
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Builds a starter CSV for <c>/students/import</c> with roster columns plus leftover note fields.
/// </summary>
public static class StudentImportCsvTemplate
{
public static readonly string[] RosterHeaders =
[
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID"
];
/// <summary>
/// Returns a CSV with a header row and one example data row.
/// </summary>
public static string Build(IEnumerable<string>? leftoverFieldNames = null)
{
List<string> leftovers = leftoverFieldNames?
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.Where(name => !StudentNotesFieldParser.IsReservedHeader(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
if (leftovers.Count == 0)
leftovers = [.. StudentNoteFieldDefaults.IndexColumns];
var headers = RosterHeaders.Concat(leftovers).ToArray();
var values = headers.Select(ExampleValue).ToArray();
return $"{ToCsvRow(headers)}{Environment.NewLine}{ToCsvRow(values)}{Environment.NewLine}";
}
private static string ExampleValue(string header) => header switch
{
"Student Name" => "Last, First",
"Grade" => "9",
"TSA year" => "1st",
"Interview Time" => "3:20-3:35",
_ when ContainsIgnoreCase(header, "Application")
|| ContainsIgnoreCase(header, "Permission") => "x",
_ => string.Empty
};
private static bool ContainsIgnoreCase(string value, string part) =>
value.Contains(part, StringComparison.OrdinalIgnoreCase);
private static string ToCsvRow(IEnumerable<string> cells) =>
string.Join(",", cells.Select(EscapeCsv));
private static string EscapeCsv(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+164
View File
@@ -0,0 +1,164 @@
using Core.Entities;
using Core.Models;
using Core.Notes;
namespace Core.Parsers;
/// <summary>
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
/// </summary>
public class StudentNotesFieldParser : CsvParserBase
{
private static readonly HashSet<string> ReservedHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID",
"Officer",
"TOTAL # OF EVENTS"
};
public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{
}
public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
/// <summary>
/// Roster and ranking columns that must not become imported note fields.
/// </summary>
public static bool IsReservedHeader(string? header)
{
if (string.IsNullOrWhiteSpace(header))
return true;
var trimmed = header.Trim();
if (ReservedHeaders.Contains(trimmed))
return true;
return int.TryParse(trimmed, out var rank)
&& rank >= 1
&& rank <= StudentEventRanking.MaxRank;
}
/// <summary>
/// Leftover field names from a header row after reserved columns are removed.
/// </summary>
public static List<string> GetLeftoverFieldNames(IEnumerable<string?> headers) =>
headers
.Where(h => !IsReservedHeader(h))
.Select(h => h!.Trim())
.ToList();
/// <summary>
/// Reads the header row and returns leftover field names without processing data rows.
/// </summary>
public List<string> PeekLeftoverFieldNames()
{
CsvReader.Read();
CsvReader.ReadHeader();
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
}
public StudentNotesImportResult Parse(
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var result = new StudentNotesImportResult();
CsvReader.Read();
CsvReader.ReadHeader();
if (CsvReader.HeaderRecord is null ||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
{
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord);
result.FieldNames = fieldNames;
if (fieldNames.Count == 0)
result.Warnings.Add("No leftover field columns were found besides roster and ranking columns.");
Dictionary<int, PendingStudentFields> pendingByStudentId = [];
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentNotesImportIssue
{
RowNumber = rowNumber,
RawStudentName = name,
Message = $"No student matched '{name}'."
});
continue;
}
var (student, score) = studentMatch.Value;
if (!pendingByStudentId.TryGetValue(student.Id, out var pending))
{
pending = new PendingStudentFields(student);
pendingByStudentId[student.Id] = pending;
}
pending.RawStudentName = name;
pending.RowNumber = rowNumber;
pending.StudentScore = score;
foreach (var fieldName in fieldNames)
{
var raw = CsvReader.GetField(fieldName);
pending.Fields[fieldName.Trim()] = raw ?? string.Empty;
}
}
foreach (var pending in pendingByStudentId.Values)
{
List<ImportedField> incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))];
existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown);
var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming);
result.Matches.Add(new StudentNotesImportMatch
{
Student = pending.Student,
RawStudentName = pending.RawStudentName,
RowNumber = pending.RowNumber,
StudentScore = pending.StudentScore,
IncomingFields = incoming,
Merge = merge
});
}
if (result.Matches.Count == 0 && result.Errors.Count == 0)
result.Warnings.Add("No students were matched from the CSV.");
return result;
}
private sealed class PendingStudentFields(Student student)
{
public Student Student { get; } = student;
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}
+45
View File
@@ -0,0 +1,45 @@
using Core.Entities;
namespace Core.Printing;
public sealed record EventMark(
string Symbol,
string Label,
string Color,
Func<EventDefinition, bool> Applies);
/// <summary>
/// Compact event-attribute marks used on the event-ranking index chip,
/// print badges, and the shared legend.
/// </summary>
public static class EventAttributeMarks
{
public const string LevelOfEffort1 = "○";
public const string LevelOfEffort2 = "◐";
public const string LevelOfEffort3 = "⬤";
public const string Individual = "ⓘ";
public const string OnSite = "ⓐ";
public const string Regional = "ⓡ";
public const string Presubmission = "ⓟ";
public static readonly IReadOnlyList<EventMark> LegendItems =
[
new(LevelOfEffort1, "Level of Effort: 1", "#757575", e => e.LevelOfEffort == 1),
new(LevelOfEffort2, "Level of Effort: 2", "#616161", e => e.LevelOfEffort == 2),
new(LevelOfEffort3, "Level of Effort: 3", "#424242", e => e.LevelOfEffort == 3),
new(Individual, "Individual Event", "#9c27b0", e => e.EventFormat == EventFormat.Individual),
new(OnSite, "On-Site Activity", "#ff9800", e => e.OnSiteActivity),
new(Regional, "Regional Event", "#2196f3", e => e.RegionalEvent),
new(Presubmission, "Presubmission", "#4caf50", e => e.Presubmission)
];
public static string For(EventDefinition? evt)
{
if (evt is null)
return string.Empty;
return string.Join(
" ",
LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
}
}
+53
View File
@@ -0,0 +1,53 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Rank labels and colors shared by the ranking index, print badges, and legend.
/// </summary>
public static class EventRankLegend
{
public static readonly IReadOnlyList<(int Rank, string Label)> Items =
[
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
.Select(rank => (rank, Ordinal(rank)))
];
public static string Ordinal(int num)
{
if (num <= 0)
return num.ToString();
switch (num % 100)
{
case 11:
case 12:
case 13:
return num + "th";
}
return (num % 10) switch
{
1 => num + "st",
2 => num + "nd",
3 => num + "rd",
_ => num + "th"
};
}
public static string ColorHex(int rank) =>
rank switch
{
1 => "#dd7e6b",
2 => "#ea9999",
3 => "#f9cb9c",
4 => "#ffe599",
5 => "#fff2cc",
6 => "#fffaea",
7 => "#fffefa",
8 => "#fffefc",
9 => "#fffffd",
10 => "#fffffe",
_ => "#ddd"
};
}
+108
View File
@@ -0,0 +1,108 @@
namespace Core.Printing;
/// <summary>
/// Splits a markdown template that contains exactly one table so a roster can
/// share one header and append merged body rows per record.
/// </summary>
public sealed class MarkdownTableStencil
{
public required string Prefix { get; init; }
public required string Header { get; init; }
public required string Body { get; init; }
public required string Suffix { get; init; }
public static bool TryParse(string? template, out MarkdownTableStencil? stencil)
{
stencil = null;
if (string.IsNullOrWhiteSpace(template))
return false;
var lines = SplitLines(template);
if (!TryFindTable(lines, 0, out var headerIndex, out var bodyStart, out var bodyEnd))
return false;
if (bodyStart >= bodyEnd)
return false;
if (TryFindTable(lines, bodyEnd, out _, out _, out _))
return false;
stencil = new MarkdownTableStencil
{
Prefix = JoinLines(lines[..headerIndex]),
Header = JoinLines(lines[headerIndex..bodyStart]),
Body = JoinLines(lines[bodyStart..bodyEnd]),
Suffix = JoinLines(lines[bodyEnd..])
};
return true;
}
public string Stitch(string prefix, IEnumerable<string> bodies, string suffix)
{
ArgumentNullException.ThrowIfNull(bodies);
List<string> parts = [];
AppendPart(parts, prefix);
AppendPart(parts, Header);
foreach (var body in bodies)
AppendPart(parts, body);
AppendPart(parts, suffix);
return parts.Count == 0 ? string.Empty : string.Join('\n', parts) + "\n";
}
private static void AppendPart(List<string> parts, string? part)
{
if (string.IsNullOrEmpty(part))
return;
var trimmed = part.TrimEnd('\r', '\n');
if (trimmed.Length > 0)
parts.Add(trimmed);
}
private static bool TryFindTable(
string[] lines,
int start,
out int headerIndex,
out int bodyStart,
out int bodyEnd)
{
headerIndex = -1;
bodyStart = -1;
bodyEnd = -1;
for (var i = start; i < lines.Length - 1; i++)
{
if (!IsTableLine(lines[i]) || !IsSeparatorLine(lines[i + 1]))
continue;
headerIndex = i;
bodyStart = i + 2;
bodyEnd = bodyStart;
while (bodyEnd < lines.Length && IsTableLine(lines[bodyEnd]) && !IsSeparatorLine(lines[bodyEnd]))
bodyEnd++;
return true;
}
return false;
}
private static bool IsTableLine(string line)
{
var trimmed = line.TrimStart();
return trimmed.StartsWith('|');
}
private static bool IsSeparatorLine(string line) =>
IsTableLine(line) && line.Contains("---", StringComparison.Ordinal);
private static string[] SplitLines(string text) =>
text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
private static string JoinLines(string[] lines) =>
lines.Length == 0 ? string.Empty : string.Join('\n', lines);
}
+110
View File
@@ -0,0 +1,110 @@
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.
/// <c>{{RankedEvents}}</c> and <c>{{RankedStudents}}</c> become ranking-index badges.
/// <c>{{Legend}}</c> becomes the shared attribute-mark legend.
/// </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\">&nbsp;</div>";
public const string LegendToken = "Legend";
public const string LegendSentinel = "<!--tsa-legend-->";
public const string RankedEventsToken = "RankedEvents";
public const string RankedStudentsToken = "RankedStudents";
public static readonly string[] HtmlFragmentTokens =
[
RankedEventsToken,
RankedStudentsToken
];
public static string HtmlFragmentSentinel(string name) => $"<!--tsa-html:{name}-->";
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens =
[
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml),
new(LegendToken, LegendSentinel, PrintRankBadgeHtml.Legend())
];
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;
}
foreach (var htmlName in HtmlFragmentTokens)
{
if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase))
return HtmlFragmentSentinel(htmlName);
}
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,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
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);
}
foreach (var name in HtmlFragmentTokens)
{
var sentinel = HtmlFragmentSentinel(name);
var fragment = htmlFragments is not null
&& htmlFragments.TryGetValue(name, out var value)
? value ?? string.Empty
: string.Empty;
html = html
.Replace($"<p>{sentinel}</p>", fragment, StringComparison.Ordinal)
.Replace(sentinel, fragment, StringComparison.Ordinal);
}
return html;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Core.Printing;
/// <summary>
/// Entity a print preset merges a note onto.
/// </summary>
public enum PrintEntityType
{
Student,
Team,
Event
}
+114
View File
@@ -0,0 +1,114 @@
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,
NoteTemplateMerger.LegendToken
];
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"
];
/// <summary>
/// <c>Rank1</c>…<c>Rank10</c> and matching <c>.ShortName</c> tokens from
/// <see cref="StudentRankTokens"/>.
/// </summary>
public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames];
public static readonly string[] StudentRanks1To6 = [.. RankTokens(1, 6)];
public static readonly string[] StudentRanks7To10 = [.. RankTokens(7, 10)];
public static IReadOnlyList<string> RankTokens(int fromRank, int toRank) =>
[
.. Enumerable.Range(fromRank, toRank - fromRank + 1)
.SelectMany(rank => (string[])
[
StudentRankTokens.NameToken(rank),
StudentRankTokens.ShortNameToken(rank),
StudentRankTokens.AttributesToken(rank)
])
];
public static readonly string[] Team =
[
"Identifier",
"Name",
"EventName",
"EventShortName",
"EventFormat",
"TeamSize",
"NationalEligibility",
"Eligibility",
"RegionalTeamCount",
"StateTeamCount",
"Description",
"Theme",
"EventAttributes"
];
public static readonly string[] Event =
[
"Name",
"ShortName",
"EventFormat",
"TeamSize",
"NationalEligibility",
"Eligibility",
"RegionalTeamCount",
"StateTeamCount",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
"Description",
"Theme",
"Documentation",
"Notes",
"EventAttributes",
NoteTemplateMerger.RankedStudentsToken
];
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) =>
entityType switch
{
PrintEntityType.Student => [.. Chapter, .. Student, .. StudentRanks],
_ => [.. Chapter, .. EntityTokens(entityType)]
};
}
+94
View File
@@ -0,0 +1,94 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Filter payload stored on a <see cref="PrintPreset"/>. Unused fields stay null.
/// </summary>
public class PrintPresetFilters
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public int? Grade { get; set; }
public int? TsaYear { get; set; }
/// <summary>
/// <c>true</c> officers only, <c>false</c> non-officers only, <c>null</c> any.
/// </summary>
public bool? IsOfficer { get; set; }
public string? TeamIdentifierContains { get; set; }
public string? EventNameContains { get; set; }
public EventFormat? EventFormat { get; set; }
/// <summary>
/// <c>true</c> regional only, <c>false</c> non-regional only, <c>null</c> any.
/// </summary>
public bool? RegionalOnly { get; set; }
/// <summary>
/// When true (default), each merged record starts a new printed page.
/// <c>{{PageBreak}}</c> in the template still works either way.
/// </summary>
public bool NewPagePerRecord { get; set; } = true;
public const int DefaultFontSizePt = 12;
public const int MinFontSizePt = 9;
public const int MaxFontSizePt = 18;
public const int DefaultAnswerSpaceLines = 3;
public const int MinAnswerSpaceLines = 1;
public const int MaxAnswerSpaceLines = 8;
/// <summary>
/// Body font size in points for merged pages.
/// </summary>
public int FontSizePt { get; set; } = DefaultFontSizePt;
/// <summary>
/// Ruled write-in lines for each <c>{{AnswerSpace}}</c>.
/// </summary>
public int AnswerSpaceLines { get; set; } = DefaultAnswerSpaceLines;
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static PrintPresetFilters FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new PrintPresetFilters();
var filters = JsonSerializer.Deserialize<PrintPresetFilters>(json, JsonOptions)
?? new PrintPresetFilters();
filters.ClampPrintOptions();
return filters;
}
public void ClampPrintOptions()
{
FontSizePt = Math.Clamp(FontSizePt, MinFontSizePt, MaxFontSizePt);
AnswerSpaceLines = Math.Clamp(AnswerSpaceLines, MinAnswerSpaceLines, MaxAnswerSpaceLines);
}
public static string ToTriState(bool? value) => value switch
{
true => "yes",
false => "no",
_ => "any"
};
public static bool? FromTriState(string? value) => value switch
{
"yes" => true,
"no" => false,
_ => null
};
}
+89
View File
@@ -0,0 +1,89 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Ranking-index style badges: colored rank dot plus a short label.
/// Student pages list ranked events; event pages list students who ranked them.
/// </summary>
public static class PrintRankBadgeHtml
{
public static string ForStudentEvents(IEnumerable<StudentEventRanking>? rankings)
{
if (rankings is null)
return string.Empty;
var badges = rankings
.Where(r => r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
.OrderBy(r => r.Rank)
.Select(EventBadge)
.ToList();
return Wrap(badges);
}
public static string ForEventStudents(IEnumerable<StudentEventRanking>? rankings)
{
if (rankings is null)
return string.Empty;
var badges = rankings
.Where(r => r.Student is not null && r.Rank is >= 1 and <= StudentEventRanking.MaxRank)
.OrderBy(r => r.Rank)
.ThenByDescending(r => r.Student.Grade + r.Student.TsaYear)
.Select(r => StudentBadge(r.Student.FirstName, r.Rank))
.ToList();
return Wrap(badges);
}
internal static string EventBadge(StudentEventRanking ranking)
{
var evt = ranking.EventDefinition;
var label = !string.IsNullOrWhiteSpace(evt?.ShortName)
? evt.ShortName
: evt?.Name;
var attributes = EventAttributeMarks.For(evt);
var attrsHtml = string.IsNullOrEmpty(attributes)
? string.Empty
: $"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(attributes)}</span>";
return Badge(ranking.Rank, PrintTokenMap.EscapeHtml(label), attrsHtml);
}
internal static string StudentBadge(string? firstName, int rank) =>
Badge(rank, PrintTokenMap.EscapeHtml(firstName), string.Empty);
private static string Badge(int rank, string labelHtml, string extraHtml)
{
var extra = string.IsNullOrEmpty(extraHtml) ? string.Empty : $" {extraHtml}";
return
$"<span class=\"print-rank-badge\">" +
$"<span class=\"print-rank-dot event-rank-{rank}\"></span> " +
$"{labelHtml}{extra}" +
"</span>";
}
public static string Legend()
{
var marks = EventAttributeMarks.LegendItems
.Select(mark =>
"<span class=\"print-legend-mark\">" +
$"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(mark.Symbol)}</span> " +
PrintTokenMap.EscapeHtml(mark.Label) +
"</span>");
return
"<div class=\"print-badge-legend\">" +
$"<div class=\"print-attr-legend\">{string.Join(" &middot; ", marks)}</div>" +
"</div>";
}
private static string Wrap(IReadOnlyList<string> badges, string separator = " ")
{
if (badges.Count == 0)
return string.Empty;
return $"<div class=\"print-rank-badges\">{string.Join(separator, badges)}</div>";
}
}
+76
View File
@@ -0,0 +1,76 @@
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.
/// Line breaks are flattened so a value cannot end a markdown table row.
/// </summary>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return FlattenLines(value)
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("|", "\\|", StringComparison.Ordinal)
.Replace("*", "\\*", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal)
.Replace("`", "\\`", StringComparison.Ordinal)
.Replace("[", "\\[", StringComparison.Ordinal)
.Replace("]", "\\]", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
/// <summary>
/// Escapes a value for insertion into generated print HTML (badge labels).
/// </summary>
public static string EscapeHtml(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return FlattenLines(value)
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal);
}
private static string FlattenLines(string value) =>
string.Join(' ',
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
+57
View File
@@ -0,0 +1,57 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Per-student event-rank merge tokens. <c>Rank1</c> is the official event name
/// at rank 1; <c>Rank1.ShortName</c> is the catalog short name. Every rank
/// through <see cref="StudentEventRanking.MaxRank"/> is always a map key so a
/// missing preference prints blank instead of leaving <c>{{Rank5}}</c> visible.
/// </summary>
public static class StudentRankTokens
{
public static string NameToken(int rank) => $"Rank{rank}";
public static string ShortNameToken(int rank) => $"Rank{rank}.ShortName";
public static string AttributesToken(int rank) => $"Rank{rank}.Attributes";
public static IReadOnlyList<string> AllNames { get; } =
[
NoteTemplateMerger.RankedEventsToken,
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
.SelectMany(rank => (string[])
[
NameToken(rank),
ShortNameToken(rank),
AttributesToken(rank)
])
];
public static Dictionary<string, string?> FromRankings(IEnumerable<StudentEventRanking>? rankings)
{
var map = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
{
map[NameToken(rank)] = null;
map[ShortNameToken(rank)] = null;
map[AttributesToken(rank)] = null;
}
if (rankings is null)
return map;
foreach (var ranking in rankings)
{
if (ranking.Rank < 1 || ranking.Rank > StudentEventRanking.MaxRank)
continue;
var evt = ranking.EventDefinition;
map[NameToken(ranking.Rank)] = evt?.Name;
map[ShortNameToken(ranking.Rank)] = evt?.ShortName;
map[AttributesToken(ranking.Rank)] = EventAttributeMarks.For(evt);
}
return map;
}
}
+15
View File
@@ -35,4 +35,19 @@ public interface INoteNamingService
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle);
/// <summary>
/// Gets the title for a student note. Format: "#Student:{id}"
/// </summary>
string GetStudentNoteTitle(int studentId);
/// <summary>
/// Checks if a note title is a student note.
/// </summary>
bool IsStudentNote(string? noteTitle);
/// <summary>
/// Parses the student id from a student note title.
/// </summary>
bool TryParseStudentNoteId(string? noteTitle, out int studentId);
}
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student field CSVs and merges them into markdown notes.
/// </summary>
public interface IStudentNotesImportService
{
StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId);
}
+23
View File
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
private const string StudentNotePrefix = "#Student:";
/// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate)
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public string GetStudentNoteTitle(int studentId) => $"{StudentNotePrefix}{studentId}";
/// <inheritdoc/>
public bool IsStudentNote(string? noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
return false;
return noteTitle.StartsWith(StudentNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool TryParseStudentNoteId(string? noteTitle, out int studentId)
{
studentId = 0;
if (!IsStudentNote(noteTitle))
return false;
return int.TryParse(noteTitle.AsSpan(StudentNotePrefix.Length), out studentId);
}
}
+54
View File
@@ -0,0 +1,54 @@
using Core.Models;
namespace Core.Services;
/// <summary>
/// Decides which parsed student notes should be created or updated.
/// Unchanged merges are omitted so a re-import does not write history.
/// </summary>
public static class StudentNotesImportPlan
{
/// <summary>
/// Builds persist actions from a parse result. Only matches with field changes are included.
/// </summary>
public static IReadOnlyList<StudentNotePersistAction> Create(
StudentNotesImportResult parseResult,
IReadOnlySet<int> studentIdsWithExistingNotes)
{
ArgumentNullException.ThrowIfNull(parseResult);
ArgumentNullException.ThrowIfNull(studentIdsWithExistingNotes);
List<StudentNotePersistAction> actions = [];
foreach (var match in parseResult.Matches)
{
if (!match.Merge.Changed)
continue;
actions.Add(new StudentNotePersistAction
{
StudentId = match.Student.Id,
Markdown = match.Merge.Markdown,
Kind = studentIdsWithExistingNotes.Contains(match.Student.Id)
? StudentNotePersistKind.Update
: StudentNotePersistKind.Create
});
}
return actions;
}
}
public enum StudentNotePersistKind
{
Create,
Update
}
public class StudentNotePersistAction
{
public required int StudentId { get; init; }
public required string Markdown { get; init; }
public required StudentNotePersistKind Kind { get; init; }
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentNotesFieldParser"/> for stream-based import.
/// </summary>
public class StudentNotesImportService : IStudentNotesImportService
{
/// <inheritdoc />
public StudentNotesImportResult Parse(
Stream stream,
ICollection<Student> students,
IReadOnlyDictionary<int, string?> existingNotesByStudentId)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, existingNotesByStudentId);
}
}
+1
View File
@@ -15,6 +15,7 @@ namespace Data
public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public DbSet<PrintPreset> PrintPresets { get; set; }
public AppDbContext()
{
@@ -0,0 +1,33 @@
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.Property(p => p.TemplateMarkdown)
.IsRequired()
.HasColumnType("TEXT");
}
}
@@ -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");
}
}
}
@@ -0,0 +1,89 @@
using System;
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260903010000_PrintPresetTemplateMarkdown")]
public partial class PrintPresetTemplateMarkdown : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TemplateMarkdown",
table: "PrintPresets",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
UPDATE PrintPresets
SET TemplateMarkdown = COALESCE(
(SELECT Content FROM Notes WHERE Notes.Id = PrintPresets.NoteId),
''
);
""");
migrationBuilder.Sql(
"""
CREATE TABLE "PrintPresets_new" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"TemplateMarkdown" TEXT NOT NULL,
"EntityType" TEXT NOT NULL,
"FiltersJson" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
migrationBuilder.Sql(
"""
INSERT INTO "PrintPresets_new" ("Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt")
SELECT "Id", "Name", "TemplateMarkdown", "EntityType", "FiltersJson", "UpdatedAt"
FROM "PrintPresets";
""");
migrationBuilder.Sql("""DROP TABLE "PrintPresets";""");
migrationBuilder.Sql("""ALTER TABLE "PrintPresets_new" RENAME TO "PrintPresets";""");
migrationBuilder.Sql(
"""CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
CREATE TABLE "PrintPresets_old" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_PrintPresets" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"NoteId" INTEGER NOT NULL,
"EntityType" TEXT NOT NULL,
"FiltersJson" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
migrationBuilder.Sql(
"""
INSERT INTO "PrintPresets_old" ("Id", "Name", "NoteId", "EntityType", "FiltersJson", "UpdatedAt")
SELECT "Id", "Name", 0, "EntityType", "FiltersJson", "UpdatedAt"
FROM "PrintPresets";
""");
migrationBuilder.Sql("""DROP TABLE "PrintPresets";""");
migrationBuilder.Sql("""ALTER TABLE "PrintPresets_old" RENAME TO "PrintPresets";""");
migrationBuilder.Sql(
"""CREATE UNIQUE INDEX "IX_PrintPresets_Name" ON "PrintPresets" ("Name");""");
migrationBuilder.Sql(
"""CREATE INDEX "IX_PrintPresets_NoteId" ON "PrintPresets" ("NoteId");""");
}
}
}
@@ -177,6 +177,41 @@ namespace Data.Migrations
b.ToTable("EventOccurrences");
});
modelBuilder.Entity("Core.Entities.PrintPreset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("TemplateMarkdown")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PrintPresets");
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Property<int>("Id")
+207
View File
@@ -0,0 +1,207 @@
using Core.Notes;
namespace Tests.Notes;
[TestFixture]
public class ImportedFieldsTable_Tests
{
[Test]
public void NormalizeValue_X_BecomesYes()
{
Assert.That(ImportedFieldsTable.NormalizeValue("x"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.NormalizeValue("X"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.NormalizeValue(" x "), Is.EqualTo("Yes"));
}
[Test]
public void NormalizeValue_Blank_StaysEmpty()
{
Assert.That(ImportedFieldsTable.NormalizeValue(null), Is.EqualTo(string.Empty));
Assert.That(ImportedFieldsTable.NormalizeValue(" "), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_IncomingWins_AndAppendsNewFields()
{
var existing = """
Freeform note
## Imported fields
| Field | Value |
| --- | --- |
| Interview Time | 1:00-1:15 |
| Application | Yes |
""";
var result = ImportedFieldsTable.Merge(existing,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 1", "Fuqua")
]);
Assert.That(result.Changed, Is.True);
Assert.That(result.Changes, Has.Count.EqualTo(2));
var fields = ImportedFieldsTable.ParseFields(result.Markdown);
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
Assert.That(result.Markdown, Does.Contain("Freeform note"));
Assert.That(result.Markdown, Does.Contain(ImportedFieldsTable.Heading));
Assert.That(result.Markdown, Does.Not.Contain("## Imported fields"));
}
[Test]
public void ParseFields_ReadsAnyFieldsHeading()
{
foreach (var heading in new[] { "## Additional fields", "## Imported fields", "## Extra fields" })
{
var markdown = $"""
{heading}
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Allergies"), Is.EqualTo("peanuts"), heading);
}
var otherHeading = """
## Advisor comments
| Field | Value |
| --- | --- |
| Allergies | peanuts |
""";
Assert.That(ImportedFieldsTable.GetFieldValue(otherHeading, "Allergies"), Is.Null);
}
[Test]
public void Merge_SameDataTwice_IsNoOp()
{
var first = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "x")
]);
Assert.That(first.Changed, Is.True);
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown).Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
var second = ImportedFieldsTable.Merge(first.Markdown,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "x")
]);
Assert.That(second.Changed, Is.False);
Assert.That(second.Changes, Is.Empty);
Assert.That(ImportedFieldsTable.ParseFields(second.Markdown), Is.EqualTo(ImportedFieldsTable.ParseFields(first.Markdown)));
}
[Test]
public void GetFieldValue_ReadsImportedTable()
{
var markdown = ImportedFieldsTable.FormatSection(
[
new ImportedField("Interview Time", "3:20-3:35")
]);
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Interview Time"), Is.EqualTo("3:20-3:35"));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.Null);
}
[Test]
public void Merge_PreservesTextAfterSection()
{
var existing = """
## Imported fields
| Field | Value |
| --- | --- |
| Application | Yes |
## Advisor comments
Great interview.
""";
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Application", "Yes")]);
Assert.That(result.Changed, Is.False);
Assert.That(result.Markdown, Does.Contain("## Advisor comments"));
Assert.That(result.Markdown, Does.Contain("Great interview."));
}
[Test]
public void ParseFields_KeepsBlankValues()
{
var markdown = ImportedFieldsTable.FormatSection(
[
new ImportedField("Application", "Yes"),
new ImportedField("Teacher Rec 3", "")
]);
var fields = ImportedFieldsTable.ParseFields(markdown);
Assert.That(fields.Single(f => f.Name == "Teacher Rec 3").Value, Is.EqualTo(string.Empty));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
}
[Test]
public void Merge_BlankFields_SameDataTwice_IsNoOp()
{
var first = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 3", "")
]);
Assert.That(first.Changed, Is.True);
Assert.That(ImportedFieldsTable.ParseFields(first.Markdown), Has.Count.EqualTo(2));
var second = ImportedFieldsTable.Merge(first.Markdown,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Teacher Rec 3", "")
]);
Assert.That(second.Changed, Is.False);
Assert.That(second.Changes, Is.Empty);
}
[Test]
public void Merge_IncomingBlank_ClearsExistingValue()
{
var existing = ImportedFieldsTable.Merge(null, [new ImportedField("Teacher Rec 1", "Fuqua")]).Markdown;
var result = ImportedFieldsTable.Merge(existing, [new ImportedField("Teacher Rec 1", "")]);
Assert.That(result.Changed, Is.True);
Assert.That(ImportedFieldsTable.GetFieldValue(result.Markdown, "Teacher Rec 1"), Is.EqualTo(string.Empty));
}
[Test]
public void GetFieldValue_IsCaseInsensitive()
{
var markdown = ImportedFieldsTable.FormatSection([new ImportedField("Interview Time", "3:20-3:35")]);
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "interview time"), Is.EqualTo("3:20-3:35"));
}
[Test]
public void DistinctFieldNames_UnionsNotes_AndKeepsFirstCasing()
{
var first = ImportedFieldsTable.FormatSection(
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "Yes")
]);
var second = ImportedFieldsTable.FormatSection(
[
new ImportedField("application", "Yes"),
new ImportedField("Teacher Rec 1", "Fuqua")
]);
var names = ImportedFieldsTable.DistinctFieldNames([first, second, null, ""]);
Assert.That(names, Is.EqualTo(new[] { "Application", "Interview Time", "Teacher Rec 1" }));
}
}
@@ -0,0 +1,30 @@
using Core.Parsers;
namespace Tests.Parsers;
[TestFixture]
public class StudentImportCsvTemplate_Tests
{
[Test]
public void Build_IncludesRosterAndDefaultLeftoverColumns()
{
var csv = StudentImportCsvTemplate.Build();
var header = csv.Split('\n')[0].TrimEnd('\r');
Assert.That(header, Does.StartWith("Student Name,Grade,TSA year,State ID,Regional ID,National ID"));
Assert.That(header, Does.Contain("Interview Time"));
Assert.That(header, Does.Contain("Application"));
Assert.That(csv, Does.Contain("\"Last, First\""));
}
[Test]
public void Build_UsesConfiguredLeftoverFields_AndSkipsReserved()
{
var csv = StudentImportCsvTemplate.Build(["Teacher Rec 1", "Grade", " Application "]);
var header = csv.Split('\n')[0].TrimEnd('\r');
Assert.That(header, Does.Contain("Teacher Rec 1"));
Assert.That(header, Does.Contain("Application"));
Assert.That(header.Split(',').Count(c => c.Equals("Grade", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1));
}
}
@@ -0,0 +1,196 @@
using System.Text;
using Core.Notes;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class StudentNotesFieldParser_Tests
{
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
}
[Test]
public void Parse_MatchesStudentAndReportsUnmatched()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Interview Time,Application
"Chittenden, Aria",3:20-3:35,x
Nobody Here,1:00-1:15,x
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Student, Is.SameAs(aria));
Assert.That(result.Matches[0].Merge.Changed, Is.True);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_SameExistingNote_HasNoChanges()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var existing = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "3:20-3:35"),
new ImportedField("Application", "Yes")
]).Markdown;
const string csv = """
Student Name,Interview Time,Application
Aria Chittenden,3:20-3:35,x
""";
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Merge.Changed, Is.False);
Assert.That(result.StudentsWithChanges, Is.EqualTo(0));
}
[Test]
public void Parse_MissingStudentNameColumn_IsError()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Name,Interview Time
Aria Chittenden,3:20-3:35
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Errors, Has.Some.Contains("Student Name"));
Assert.That(result.Matches, Is.Empty);
}
[Test]
public void Parse_PreservesFieldsNotInCsv()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var existing = ImportedFieldsTable.Merge(null,
[
new ImportedField("Interview Time", "1:00-1:15"),
new ImportedField("Teacher Rec 1", "Fuqua")
]).Markdown;
const string csv = """
Student Name,Interview Time
Aria Chittenden,3:20-3:35
""";
var result = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = existing });
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.Matches[0].Merge.Changed, Is.True);
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Fuqua"));
}
[Test]
public void Parse_X_BecomesYes_AndBlankFieldsRoundTrip()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Application,Teacher Rec 3
Aria Chittenden,x,
""";
var first = Parse(csv, [aria]);
var markdown = first.Matches[0].Merge.Markdown;
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Application"), Is.EqualTo("Yes"));
Assert.That(ImportedFieldsTable.GetFieldValue(markdown, "Teacher Rec 3"), Is.EqualTo(string.Empty));
var second = Parse(csv, [aria], new Dictionary<int, string?> { [aria.Id] = markdown });
Assert.That(second.Matches[0].Merge.Changed, Is.False);
Assert.That(second.StudentsWithChanges, Is.EqualTo(0));
}
[Test]
public void Parse_DuplicateStudentRows_LastValueWins_OneMatch()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Interview Time,Teacher Rec 1
Aria Chittenden,1:00-1:15,Fuqua
"Chittenden, Aria",3:20-3:35,Young
""";
var result = Parse(csv, [aria]);
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(fields.Single(f => f.Name == "Interview Time").Value, Is.EqualTo("3:20-3:35"));
Assert.That(fields.Single(f => f.Name == "Teacher Rec 1").Value, Is.EqualTo("Young"));
}
[Test]
public void Parse_NameOnlyCsv_WarnsAndMatchesWithoutFieldChanges()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name
Aria Chittenden
""";
var result = Parse(csv, [aria]);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Merge.Changed, Is.False);
}
[Test]
public void Parse_ExcludesRosterAndRankingColumns()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Grade,TSA year,State ID,Regional ID,National ID,Officer,1,2,TOTAL # OF EVENTS,Interview Time,Application
Aria Chittenden,6,1st,,,,,Coding,JSS,2,3:20-3:35,x
""";
var result = Parse(csv, [aria]);
var fields = ImportedFieldsTable.ParseFields(result.Matches[0].Merge.Markdown);
Assert.That(result.FieldNames, Is.EquivalentTo(["Interview Time", "Application"]));
Assert.That(fields.Select(f => f.Name), Is.EquivalentTo(["Interview Time", "Application"]));
Assert.That(fields.Single(f => f.Name == "Application").Value, Is.EqualTo("Yes"));
Assert.That(fields.Any(f => f.Name is "Grade" or "1" or "Officer"), Is.False);
}
[Test]
public void Parse_RosterOnlyCsv_HasNoLeftoverFields()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
const string csv = """
Student Name,Grade,TSA year,State ID,Regional ID,National ID
Aria Chittenden,6,1st,,,
""";
var result = Parse(csv, [aria]);
Assert.That(result.FieldNames, Is.Empty);
Assert.That(result.Matches[0].Merge.Changed, Is.False);
Assert.That(result.Warnings, Has.Some.Contains("No leftover field columns"));
}
private static Core.Models.StudentNotesImportResult Parse(
string csv,
ICollection<Core.Entities.Student> students,
IReadOnlyDictionary<int, string?>? notes = null)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentNotesFieldParser(reader);
return parser.Parse(students, notes ?? new Dictionary<int, string?>());
}
}
@@ -0,0 +1,129 @@
using Core.Printing;
namespace Tests.Printing;
[TestFixture]
public class MarkdownTableStencil_Tests
{
private const string Roster = """
| Name | Grade |
| --- | --- |
| {{LastNameFirstName}} | {{Grade}} |
""";
[Test]
public void TryParse_SingleTable_SplitsPrefixHeaderBodySuffix()
{
var template = """
# Rankings
| Name | 1st |
| --- | --- |
| {{LastNameFirstName}} | {{Rank1}} |
{{Legend}}
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
Assert.That(stencil!.Prefix, Does.Contain("# Rankings"));
Assert.That(stencil.Header, Does.Contain("| Name | 1st |"));
Assert.That(stencil.Header, Does.Contain("| --- | --- |"));
Assert.That(stencil.Body.Trim(), Is.EqualTo("| {{LastNameFirstName}} | {{Rank1}} |"));
Assert.That(stencil.Suffix, Does.Contain("{{Legend}}"));
}
[Test]
public void TryParse_NoTable_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("# Interview\n\n{{FirstName}}", out _), Is.False);
}
[Test]
public void TryParse_TwoTables_Fails()
{
var template = """
| Grade | Time |
| --- | --- |
| {{Grade}} | {{Interview Time}} |
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out _), Is.False);
}
[Test]
public void TryParse_HeaderOnly_Fails()
{
Assert.That(MarkdownTableStencil.TryParse("| Name |\n| --- |\n", out _), Is.False);
}
[Test]
public void Stitch_TwoStudents_HeaderOnceAndTwoBodyRows()
{
Assert.That(MarkdownTableStencil.TryParse(Roster, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Cole, Aria"), ("Grade", "6"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Dean, Lucas"), ("Grade", "7")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Name | Grade |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
Assert.That(merged, Does.Contain("| Cole, Aria | 6 |"));
Assert.That(merged, Does.Contain("| Dean, Lucas | 7 |"));
}
[Test]
public void Stitch_AttributesRow_RepeatsPerRecord()
{
var template = """
| 1st | 2nd |
| --- | --- |
| {{Rank1}} | {{Rank2}} |
| {{Rank1.Attributes}} | {{Rank2.Attributes}} |
""";
Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
var merged = stencil!.Stitch(
string.Empty,
[
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Coding"), ("Rank2", "Flight"), ("Rank1.Attributes", "I"), ("Rank2.Attributes", "T"))),
NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Drone"), ("Rank2", "Robotics"), ("Rank1.Attributes", "R"), ("Rank2.Attributes", "O")))
],
string.Empty);
Assert.That(CountOccurrences(merged, "| Coding | Flight |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| I | T |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| Drone | Robotics |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| R | O |"), Is.EqualTo(1));
Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
}
private static Dictionary<string, string> Map(params (string Key, string Value)[] pairs)
{
var tokens = PrintTokenMap.Create();
foreach (var (key, value) in pairs)
tokens[key] = value;
return tokens;
}
private static int CountOccurrences(string haystack, string needle)
{
var count = 0;
var index = 0;
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}
return count;
}
}
+168
View File
@@ -0,0 +1,168 @@
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));
}
[Test]
public void Merge_RankedStudents_IsHtmlFragmentSentinel()
{
var result = NoteTemplateMerger.Merge(
"{{Name}}\n{{RankedStudents}}",
PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken)));
Assert.That(result, Does.Not.Contain("{{RankedStudents}}"));
}
[Test]
public void ApplyLayout_ReplacesHtmlFragmentSentinel()
{
var sentinel = NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken);
var fragments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = "<div class=\"print-rank-badges\">Aria</div>"
};
var raw = NoteTemplateMerger.ApplyLayout($"x{sentinel}y", fragments);
var wrapped = NoteTemplateMerger.ApplyLayout($"<p>{sentinel}</p>", fragments);
var missing = NoteTemplateMerger.ApplyLayout($"x{sentinel}y");
Assert.That(raw, Is.EqualTo("x<div class=\"print-rank-badges\">Aria</div>y"));
Assert.That(wrapped, Is.EqualTo("<div class=\"print-rank-badges\">Aria</div>"));
Assert.That(missing, Is.EqualTo("xy"));
}
[Test]
public void Merge_Legend_IsSentinel()
{
var result = NoteTemplateMerger.Merge("{{Legend}}", PrintTokenMap.Create());
Assert.That(result, Does.Contain(NoteTemplateMerger.LegendSentinel));
Assert.That(result, Does.Not.Contain("{{Legend}}"));
}
[Test]
public void ApplyLayout_ReplacesLegendSentinel()
{
var html = NoteTemplateMerger.ApplyLayout(
$"<p>{NoteTemplateMerger.LegendSentinel}</p>");
Assert.That(html, Does.Contain("print-badge-legend"));
Assert.That(html, Does.Contain(EventAttributeMarks.Individual));
Assert.That(html, Does.Not.Contain("event-rank-1"));
}
}
+77
View File
@@ -0,0 +1,77 @@
using Core.Entities;
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.Contain("Rank1"));
Assert.That(tokens, Does.Contain("Rank10.ShortName"));
Assert.That(tokens, Does.Contain("Rank1.Attributes"));
Assert.That(tokens, Does.Contain(NoteTemplateMerger.RankedEventsToken));
Assert.That(tokens, Does.Not.Contain("Rank11"));
Assert.That(tokens, Does.Not.Contain("Interview Time"));
}
[Test]
public void StudentRanks_MatchesMaxRankAndStaysOffEntityTokens()
{
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.ShortName"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.Attributes"));
Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain(NoteTemplateMerger.RankedEventsToken));
Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3));
Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Not.Contain("Rank1"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank1"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Contain("Rank6.Attributes"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Does.Not.Contain("Rank7"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank7"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Contain("Rank10.ShortName"));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Does.Not.Contain("Rank6"));
Assert.That(PrintFieldCatalog.StudentRanks1To6, Has.Length.EqualTo(6 * 3));
Assert.That(PrintFieldCatalog.StudentRanks7To10, Has.Length.EqualTo(4 * 3));
}
[Test]
public void BuiltInFor_TeamAndEventHaveExpectedNames()
{
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("Eligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("NationalEligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("Eligibility"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
}
[Test]
public void Layout_IncludesPageBreakAndAnswerSpace()
{
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.LegendToken));
}
[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));
}
}
+120
View File
@@ -0,0 +1,120 @@
using Core.Entities;
using Core.Printing;
using Tests.Builders;
namespace Tests.Printing;
[TestFixture]
public class PrintRankBadgeHtml_Tests
{
[Test]
public void ForStudentEvents_Empty_IsEmpty()
{
Assert.That(PrintRankBadgeHtml.ForStudentEvents([]), Is.EqualTo(string.Empty));
Assert.That(PrintRankBadgeHtml.ForStudentEvents(null), Is.EqualTo(string.Empty));
}
[Test]
public void ForStudentEvents_RendersShortNameDotAndAttributes()
{
var coding = EventDefinitionBuilder.Individual("Coding")
.WithShortName("Code")
.AsRegionalEvent()
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(coding, 1)
.Build();
var html = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings);
Assert.That(html, Does.Contain("print-rank-badges"));
Assert.That(html, Does.Contain("event-rank-1"));
Assert.That(html, Does.Contain("Code"));
Assert.That(html, Does.Contain(EventAttributeMarks.Individual));
Assert.That(html, Does.Contain(EventAttributeMarks.Regional));
Assert.That(html, Does.Not.Contain("{{"));
}
[Test]
public void ForEventStudents_SortsByRankThenSeniority()
{
var evt = EventDefinitionBuilder.Individual("Coding").Build();
var younger = StudentBuilder.Create("Bea", "Young").Build();
younger.Grade = 9;
younger.TsaYear = 1;
var older = StudentBuilder.Create("Aria", "Cole").Build();
older.Grade = 12;
older.TsaYear = 4;
var rankings = new List<StudentEventRanking>
{
new() { Student = younger, EventDefinition = evt, Rank = 1 },
new() { Student = older, EventDefinition = evt, Rank = 1 }
};
var html = PrintRankBadgeHtml.ForEventStudents(rankings);
var ariaAt = html.IndexOf("Aria", StringComparison.Ordinal);
var beaAt = html.IndexOf("Bea", StringComparison.Ordinal);
Assert.That(ariaAt, Is.GreaterThanOrEqualTo(0));
Assert.That(beaAt, Is.GreaterThan(ariaAt));
}
[Test]
public void EventAttributeMarks_IncludesEffortAndFlags()
{
var evt = EventDefinitionBuilder.Individual("Coding")
.AsOnSite()
.AsRegionalEvent()
.WithPresubmission()
.WithLevelOfEffort(2)
.Build();
var marks = EventAttributeMarks.For(evt);
Assert.That(marks, Does.Contain(EventAttributeMarks.LevelOfEffort2));
Assert.That(marks, Does.Contain(EventAttributeMarks.Individual));
Assert.That(marks, Does.Contain(EventAttributeMarks.OnSite));
Assert.That(marks, Does.Contain(EventAttributeMarks.Regional));
Assert.That(marks, Does.Contain(EventAttributeMarks.Presubmission));
}
[Test]
public void EscapeHtml_MasksTags()
{
Assert.That(PrintTokenMap.EscapeHtml("A <b>"), Is.EqualTo("A &lt;b&gt;"));
}
[Test]
public void For_UsesTheSameMarksAsTheLegend()
{
var evt = EventDefinitionBuilder.Individual("Coding")
.AsOnSite()
.AsRegionalEvent()
.WithPresubmission()
.WithLevelOfEffort(2)
.Build();
var fromCatalog = string.Join(
" ",
EventAttributeMarks.LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
Assert.That(EventAttributeMarks.For(evt), Is.EqualTo(fromCatalog));
}
[Test]
public void Legend_IncludesRankDotsAndAttributeMarks()
{
var html = PrintRankBadgeHtml.Legend();
Assert.That(html, Does.Contain("print-badge-legend"));
Assert.That(html, Does.Contain("&middot;"));
Assert.That(html, Does.Not.Contain(EventRankLegend.Ordinal(1)));
Assert.That(html, Does.Not.Contain("event-rank-1"));
foreach (var mark in EventAttributeMarks.LegendItems)
{
Assert.That(html, Does.Contain(mark.Symbol));
Assert.That(html, Does.Contain(mark.Label));
}
}
}
+105
View File
@@ -0,0 +1,105 @@
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_BuiltInRankTokenWinsOverImportedSameName()
{
var map = PrintTokenMap.Build(
new Dictionary<string, string?> { ["Rank1"] = "imported" },
new Dictionary<string, string?> { ["Rank1"] = "Coding" },
null);
Assert.That(map["Rank1"], Is.EqualTo("Coding"));
}
[Test]
public void Build_IncludesAllImportedCatalogKeys()
{
var imported = new Dictionary<string, string?>
{
["Interview Time"] = "3:20-3:35",
["Application"] = null
};
var map = PrintTokenMap.Build(imported, null, null);
Assert.That(map.ContainsKey("Interview Time"), Is.True);
Assert.That(map["Interview Time"], Is.EqualTo("3:20-3:35"));
Assert.That(map.ContainsKey("Application"), Is.True);
Assert.That(map["Application"], Is.EqualTo(string.Empty));
}
[Test]
public void Escape_MasksMarkdownAndHtml()
{
var escaped = PrintTokenMap.Escape("A *B* <script>");
Assert.That(escaped, Does.Contain("\\*"));
Assert.That(escaped, Does.Contain("&lt;"));
Assert.That(escaped, Does.Contain("&gt;"));
Assert.That(escaped, Does.Not.Contain("<script>"));
}
[Test]
public void Merge_EscapedAsteriskDoesNotStayRaw()
{
var map = PrintTokenMap.Build(
null,
new Dictionary<string, string?> { ["FirstName"] = "A*ria" },
null);
var merged = NoteTemplateMerger.Merge("Hi {{FirstName}}", map);
Assert.That(merged, Is.EqualTo("Hi A\\*ria"));
}
[Test]
public void Escape_FlattensNewlinesSoTableRowsStayIntact()
{
var escaped = PrintTokenMap.Escape("Drone Challenge (UAV)\r\n\r\n");
Assert.That(escaped, Is.EqualTo("Drone Challenge (UAV)"));
Assert.That(escaped, Does.Not.Contain('\n'));
Assert.That(escaped, Does.Not.Contain('\r'));
}
[Test]
public void Escape_EscapesPipeForMarkdownTables()
{
Assert.That(PrintTokenMap.Escape("A | B"), Is.EqualTo("A \\| B"));
}
[Test]
public void Merge_DroneNameWithTrailingNewlines_StaysOnOneTableRow()
{
var map = PrintTokenMap.Build(
null,
new Dictionary<string, string?>
{
["Rank1"] = "Drone Challenge (UAV)\n\n",
["Rank2"] = "Off the Grid"
},
null);
var merged = NoteTemplateMerger.Merge(
"| {{Rank1}} | {{Rank2}} |\n| --- | --- |",
map);
Assert.That(merged, Is.EqualTo("| Drone Challenge (UAV) | Off the Grid |\n| --- | --- |"));
}
}
+88
View File
@@ -0,0 +1,88 @@
using Core.Entities;
using Core.Printing;
using Tests.Builders;
namespace Tests.Printing;
[TestFixture]
public class StudentRankTokens_Tests
{
[Test]
public void FromRankings_AlwaysIncludesEveryRankThroughMax()
{
var map = StudentRankTokens.FromRankings([]);
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
{
Assert.That(map.ContainsKey(StudentRankTokens.NameToken(rank)), Is.True);
Assert.That(map.ContainsKey(StudentRankTokens.ShortNameToken(rank)), Is.True);
Assert.That(map[StudentRankTokens.NameToken(rank)], Is.Null);
}
}
[Test]
public void FromRankings_FillsNameAndShortNameForPresentRanks()
{
var coding = EventDefinitionBuilder.Individual("Coding")
.WithShortName("Code")
.Build();
var flight = EventDefinitionBuilder.Individual("Flight Endurance")
.WithShortName("Flight")
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(coding, 1)
.WithRanking(flight, 3)
.Build();
var map = StudentRankTokens.FromRankings(student.EventRankings);
Assert.That(map["Rank1"], Is.EqualTo("Coding"));
Assert.That(map["Rank1.ShortName"], Is.EqualTo("Code"));
Assert.That(map["Rank1.Attributes"], Is.EqualTo(EventAttributeMarks.For(coding)));
Assert.That(map["Rank3"], Is.EqualTo("Flight Endurance"));
Assert.That(map["Rank3.ShortName"], Is.EqualTo("Flight"));
Assert.That(map["Rank2"], Is.Null);
Assert.That(map["Rank2.Attributes"], Is.Null);
}
[Test]
public void FromRankings_IgnoresRanksOutsideOneToMax()
{
var evt = EventDefinitionBuilder.Individual("Coding").Build();
var rankings = new List<StudentEventRanking>
{
new() { EventDefinition = evt, Rank = 0 },
new() { EventDefinition = evt, Rank = StudentEventRanking.MaxRank + 1 }
};
var map = StudentRankTokens.FromRankings(rankings);
Assert.That(map["Rank1"], Is.Null);
}
[Test]
public void Merge_KnownEmptyRankPrintsBlank()
{
var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings([]), null);
var merged = NoteTemplateMerger.Merge("1. {{Rank1}}", map);
Assert.That(merged, Is.EqualTo("1. "));
}
[Test]
public void Merge_ReplacesRankTokens()
{
var evt = EventDefinitionBuilder.Individual("Video Game Design")
.WithShortName("VGD")
.Build();
var student = StudentBuilder.Create("Aria", "Cole")
.WithRanking(evt, 1)
.Build();
var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings(student.EventRankings), null);
var merged = NoteTemplateMerger.Merge("{{Rank1}} ({{Rank1.ShortName}})", map);
Assert.That(merged, Is.EqualTo("Video Game Design (VGD)"));
}
}
+28
View File
@@ -0,0 +1,28 @@
using Core.Services;
namespace Tests.Services;
[TestFixture]
public class NoteNamingService_Tests
{
private readonly NoteNamingService _service = new();
[Test]
public void GetStudentNoteTitle_UsesStablePrefix()
{
Assert.That(_service.GetStudentNoteTitle(42), Is.EqualTo("#Student:42"));
}
[Test]
public void IsStudentNote_AndParseId()
{
Assert.That(_service.IsStudentNote("#Student:12"), Is.True);
Assert.That(_service.IsStudentNote("#Students"), Is.False);
Assert.That(_service.TryParseStudentNoteId("#Student:12", out var id), Is.True);
Assert.That(id, Is.EqualTo(12));
Assert.That(_service.TryParseStudentNoteId("#Event Ranking", out _), Is.False);
Assert.That(_service.IsStudentNote(null), Is.False);
Assert.That(_service.TryParseStudentNoteId("#Student:", out _), Is.False);
Assert.That(_service.IsPageNote("#Student:12"), Is.True);
}
}
@@ -0,0 +1,64 @@
using Core.Models;
using Core.Notes;
using Core.Services;
using Tests.Builders;
namespace Tests.Services;
[TestFixture]
public class StudentNotesImportPlan_Tests
{
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
}
[Test]
public void Create_SkipsUnchanged_CreatesAndUpdatesChanged()
{
var aria = StudentBuilder.Create("Aria", "Chittenden").Build();
var blake = StudentBuilder.Create("Blake", "Nguyen").Build();
var casey = StudentBuilder.Create("Casey", "Ortiz").Build();
var firstWrite = ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]);
var unchanged = ImportedFieldsTable.Merge(firstWrite.Markdown, [new ImportedField("Application", "Yes")]);
var created = ImportedFieldsTable.Merge(null, [new ImportedField("Interview Time", "3:20-3:35")]);
var updated = ImportedFieldsTable.Merge(
ImportedFieldsTable.Merge(null, [new ImportedField("Application", "Yes")]).Markdown,
[new ImportedField("Application", "")]);
var parseResult = new StudentNotesImportResult
{
Matches =
[
Match(aria, unchanged),
Match(blake, created),
Match(casey, updated)
]
};
HashSet<int> existingNoteIds = [casey.Id];
var actions = StudentNotesImportPlan.Create(parseResult, existingNoteIds);
Assert.That(actions, Has.Count.EqualTo(2));
Assert.That(actions.Any(a => a.StudentId == aria.Id), Is.False);
Assert.That(actions.Single(a => a.StudentId == blake.Id).Kind, Is.EqualTo(StudentNotePersistKind.Create));
Assert.That(actions.Single(a => a.StudentId == casey.Id).Kind, Is.EqualTo(StudentNotePersistKind.Update));
Assert.That(actions.Single(a => a.StudentId == casey.Id).Markdown, Is.EqualTo(updated.Markdown));
}
[Test]
public void Create_EmptyMatches_ReturnsNoActions()
{
var actions = StudentNotesImportPlan.Create(new StudentNotesImportResult(), new HashSet<int>());
Assert.That(actions, Is.Empty);
}
private static StudentNotesImportMatch Match(Core.Entities.Student student, ImportedFieldsMergeResult merge) =>
new()
{
Student = student,
Merge = merge
};
}
+1
View File
@@ -25,6 +25,7 @@
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script>
<script src="js/downloadFile.js"></script>
<script src="js/login.js"></script>
</body>
@@ -0,0 +1,253 @@
@page "/events/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<EventCatalogImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Catalog"
Description="Add new event definitions from CSV. Existing event names are skipped."
Icon="@AppIcons.Events"
ShowBackButton="true"
BackButtonUrl="/events" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Event</code>, <code>Team Size</code>, <code>State Count</code>.
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
<code>Documentation</code>, <code>State Presubmission</code>,
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_events is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_events.Length event(s) parsed.
@_newEventCount new, @_existingEventCount already in the database.
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _newEventCount == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private EventDefinition[]? _events;
private int _newEventCount;
private int _existingEventCount;
private string? _parseError;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading event catalog CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_events = new EventDefinitionParser(reader).Parse();
var existingNames = await Context.Events
.AsNoTracking()
.Select(e => e.Name)
.ToListAsync(token);
var existingSet = existingNames.ToHashSet();
_existingEventCount = _events.Count(e => existingSet.Contains(e.Name));
_newEventCount = _events.Length - _existingEventCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing event catalog CSV");
_events = null;
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_events is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var evt in _events)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token);
if (exists != null)
continue;
await Context.Events.AddAsync(evt, token);
added++;
}
await Context.SaveChangesAsync(token);
if (_isDisposed)
return;
Snackbar.Add($"Added {added} event(s).", Severity.Success);
NavigationManager.NavigateTo("/events");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported events");
if (!_isDisposed)
Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_events = null;
_newEventCount = 0;
_existingEventCount = 0;
_parseError = null;
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,6 +4,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using WebApp.Authentication
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@@ -13,6 +14,11 @@
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
</MudTooltip>
</AuthorizeView>
<MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip>
@@ -321,7 +321,7 @@
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
if (result is { Canceled: false })
{
// Refresh data if meeting was updated or deleted
await RefreshMeetingHistories();
@@ -664,7 +664,7 @@
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
}
}
catch (JSException ex)
catch (JSException)
{
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
}
@@ -401,7 +401,7 @@
var result = await dialog.Result;
// Refresh meeting history if dialog was saved
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadMeetingHistory();
}
@@ -11,12 +11,11 @@
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
Style="cursor: pointer;">
</MudIcon>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
aria-label="Restore team" />
@{
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
team,
@@ -0,0 +1,933 @@
@page "/print"
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Printing
@inject INotesService NotesService
@inject INotePrintService NotePrintService
@inject IPrintPresetService PrintPresetService
@inject IConfiguration Configuration
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject MarkdownTablePasteService MarkdownTablePasteService
<div class="no-print">
<PageHeader Title="Page printer"
Description="Write a markdown template, merge it onto students, teams, or events, and print."
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="5">
<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="7" Class="d-flex align-center gap-2 flex-wrap">
<MudButton Variant="Variant.Outlined"
OnClick="NewTemplate"
Disabled="@_isBusy">
New
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SavePreset"
Disabled="@_isBusy">
Save
</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>
@if (_entityType == PrintEntityType.Student)
{
<MudItem xs="12" sm="4" md="2">
<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="2">
<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="4">
<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="8">
<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 mb-2">
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.DataObject"
OnClick="OpenTokenDialog"
Disabled="@_isBusy">
Insert token
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview"
Disabled="@_isBusy">
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 (IsDirty())
{
<MudText Typo="Typo.caption" Color="Color.Warning">Unsaved changes</MudText>
}
@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.subtitle2" Class="mb-2">Template (Markdown)</MudText>
<div id="@EditorElementId" @key="_editorGeneration">
<MarkdownEditor Value="@_templateMarkdown"
ValueChanged="OnMarkdownChanged"
Placeholder="Write the printable page. Insert tokens for student, team, or event values."
AutoSaveEnabled="false"
NativeSpellChecker="false"
HideIcons="@HiddenEditorIcons" />
</div>
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">Template preview</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown only — tokens are not merged here.</MudText>
@if (string.IsNullOrWhiteSpace(_templateMarkdown))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">The template will preview here as you type.</MudText>
}
else
{
<MudPaper Elevation="0" Class="pa-3 note-print-page" Style="@($"background-color: var(--mud-palette-background-grey);{PrintPageStyle}")">
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(_templateMarkdown))
</div>
</MudPaper>
}
</MudItem>
</MudGrid>
</MudPaper>
</div>
<div class="print-only">
<PrintPageStack Pages="_pages"
NewPagePerRecord="_newPagePerRecord"
PrintPageStyle="@PrintPageStyle" />
</div>
@code {
private const string EditorElementId = "page-printer-editor";
private static readonly string[] HiddenEditorIcons = ["preview", "side-by-side", "fullscreen"];
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
private bool _isLoading = true;
private bool _isBusy;
private bool _previewStale = true;
private bool _didPreview;
private bool _pasteInitialized;
private int _editorGeneration;
private List<PrintPreset> _presets = [];
private List<string> _importedTokenNames = [];
private IReadOnlyList<NotePrintPage> _pages = [];
private IDialogReference? _previewDialog;
private IDisposable? _navigationRegistration;
private int? _selectedPresetId;
private string _templateMarkdown = string.Empty;
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 string _snapshotMarkdown = string.Empty;
private PrintEntityType _snapshotEntityType = PrintEntityType.Student;
private string _snapshotFiltersJson = string.Empty;
private string PrintPageStyle =>
$"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};";
private bool CanPreview => !string.IsNullOrWhiteSpace(_templateMarkdown);
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
CaptureSnapshot();
_navigationRegistration = NavigationManager.RegisterLocationChangingHandler(OnLocationChanging);
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_pasteInitialized || _isDisposed)
return;
await Task.Delay(150);
if (_isDisposed)
return;
await MarkdownTablePasteService.InitializeAsync(EditorElementId);
_pasteInitialized = true;
}
private async Task LoadAsync()
{
if (_isDisposed)
return;
_isLoading = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
finally
{
if (!_isDisposed)
_isLoading = false;
}
}
private async Task MarkStaleAsync()
{
_previewStale = true;
_pages = [];
await ClosePreviewDialogAsync();
}
private void MarkStale() => _ = MarkStaleAsync();
private async Task ClosePreviewDialogAsync()
{
var dialog = _previewDialog;
_previewDialog = null;
if (dialog is null)
return;
try
{
dialog.Close();
}
catch (InvalidOperationException)
{
}
}
private async Task OnEntityTypeChanged(PrintEntityType value)
{
_entityType = value;
await MarkStaleAsync();
}
private void OnMarkdownChanged(string? value)
{
_templateMarkdown = value ?? string.Empty;
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 FlushEditorAsync()
{
var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId);
if (value is not null && value != _templateMarkdown)
{
_templateMarkdown = value;
await MarkStaleAsync();
}
}
private bool IsDirty()
{
return !string.Equals(_templateMarkdown, _snapshotMarkdown, StringComparison.Ordinal)
|| _entityType != _snapshotEntityType
|| !string.Equals(BuildFilters().ToJson(), _snapshotFiltersJson, StringComparison.Ordinal);
}
private void CaptureSnapshot()
{
_snapshotMarkdown = _templateMarkdown;
_snapshotEntityType = _entityType;
_snapshotFiltersJson = BuildFilters().ToJson();
}
private async Task<bool> ConfirmDiscardIfDirtyAsync()
{
await FlushEditorAsync();
if (!IsDirty())
return true;
var confirmed = await DialogService.ShowMessageBox(
"Unsaved changes",
"Discard unsaved template or filter changes?",
yesText: "Discard",
cancelText: "Stay");
return confirmed == true && !_isDisposed;
}
private async ValueTask OnLocationChanging(LocationChangingContext context)
{
if (_isDisposed)
return;
if (!await ConfirmDiscardIfDirtyAsync())
context.PreventNavigation();
}
private async Task OnPresetSelected(int? id)
{
if (id == _selectedPresetId)
return;
if (!await ConfirmDiscardIfDirtyAsync())
return;
_selectedPresetId = id;
if (!id.HasValue)
{
await MarkStaleAsync();
return;
}
var preset = _presets.FirstOrDefault(p => p.Id == id.Value);
if (preset is null)
return;
ApplyPreset(preset);
CaptureSnapshot();
await MarkStaleAsync();
}
private async Task NewTemplate()
{
if (_isDisposed || !await ConfirmDiscardIfDirtyAsync())
return;
_selectedPresetId = null;
_templateMarkdown = string.Empty;
_entityType = PrintEntityType.Student;
_grade = null;
_tsaYear = null;
_officerChoice = "any";
_teamIdentifierContains = null;
_eventNameContains = null;
_eventFormat = null;
_regionalChoice = "any";
_newPagePerRecord = true;
_fontSizePt = PrintPresetFilters.DefaultFontSizePt;
_answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines;
_editorGeneration++;
_pasteInitialized = false;
CaptureSnapshot();
await MarkStaleAsync();
}
private void ApplyPreset(PrintPreset preset)
{
_entityType = preset.EntityType;
_templateMarkdown = preset.TemplateMarkdown ?? string.Empty;
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;
_editorGeneration++;
_pasteInitialized = false;
}
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 OpenTokenDialog()
{
if (_isDisposed)
return;
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
var parameters = new DialogParameters<PrintTokenInsertDialog>
{
{ x => x.EntityType, _entityType },
{ x => x.ImportedFieldNames, _importedTokenNames },
{ x => x.OnInsert, EventCallback.Factory.Create<string>(this, InsertToken) }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
await DialogService.ShowAsync<PrintTokenInsertDialog>("Insert token", parameters, options);
}
private async Task InsertToken(string tokenName)
{
if (_isDisposed)
return;
await FlushEditorAsync();
var wrapped = "{{" + tokenName + "}}";
var inserted = await MarkdownTablePasteService.InsertAtCursorAsync(EditorElementId, wrapped);
if (!inserted)
{
_templateMarkdown += wrapped;
await MarkdownTablePasteService.SetValueAsync(EditorElementId, _templateMarkdown);
}
else
{
var value = await MarkdownTablePasteService.GetValueAsync(EditorElementId);
if (value is not null)
_templateMarkdown = value;
}
await MarkStaleAsync();
}
private async Task Preview()
{
if (_isDisposed)
return;
await FlushEditorAsync();
if (!CanPreview)
{
if (!_isDisposed)
Snackbar.Add("Write a template before previewing.", Severity.Warning);
return;
}
if (!_previewStale && _didPreview)
{
await OpenPreviewDialogAsync();
return;
}
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
await RefreshImportedTokenNamesAsync(token);
_pages = await NotePrintService.PreviewAsync(
new NotePrintRequest
{
EntityType = _entityType,
TemplateMarkdown = _templateMarkdown,
Filters = BuildFilters(),
ImportedFieldCatalog = _importedTokenNames
},
token);
_previewStale = false;
_didPreview = true;
if (!_isDisposed)
await OpenPreviewDialogAsync();
}
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 OpenPreviewDialogAsync()
{
await ClosePreviewDialogAsync();
var parameters = new DialogParameters<PrintPreviewDialog>
{
{ x => x.Pages, _pages },
{ x => x.NewPagePerRecord, _newPagePerRecord },
{ x => x.PrintPageStyle, PrintPageStyle },
{ x => x.OnPrint, EventCallback.Factory.Create(this, Print) }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.ExtraLarge,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<PrintPreviewDialog>("Print preview", parameters, options);
_previewDialog = dialog;
_ = TrackPreviewDialog(dialog);
}
private async Task TrackPreviewDialog(IDialogReference dialog)
{
try
{
await dialog.Result;
}
catch (TaskCanceledException)
{
}
finally
{
if (_previewDialog == dialog)
_previewDialog = null;
}
}
private async Task Print()
{
if (_isDisposed || _previewStale || _pages.Count == 0)
return;
try
{
await JSRuntime.InvokeVoidAsync("window.print");
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
}
private async Task SavePreset()
{
if (_isDisposed)
return;
await FlushEditorAsync();
if (!CanPreview)
{
if (!_isDisposed)
Snackbar.Add("Write a template before saving.", Severity.Warning);
return;
}
if (_selectedPresetId.HasValue)
{
await UpdateSelectedPresetAsync();
return;
}
var name = await PromptForPresetNameAsync();
if (string.IsNullOrWhiteSpace(name) || _isDisposed)
return;
_isBusy = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
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,
TemplateMarkdown = _templateMarkdown,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
_selectedPresetId = created.Id;
CaptureSnapshot();
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<string?> PromptForPresetNameAsync()
{
var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<PrintPresetNameDialog>("Save print preset", options);
var result = await dialog.Result;
if (result is null || result.Canceled || result.Data is not string name)
return null;
return name.Trim();
}
private async Task UpdateSelectedPresetAsync()
{
if (_isDisposed || !_selectedPresetId.HasValue)
return;
_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,
TemplateMarkdown = _templateMarkdown,
EntityType = _entityType,
FiltersJson = BuildFilters().ToJson()
},
token);
_presets = [.. await PrintPresetService.GetAllAsync(token)];
CaptureSnapshot();
if (!_isDisposed)
Snackbar.Add($"Saved print preset '{existing.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 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;
CaptureSnapshot();
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;
_navigationRegistration?.Dispose();
_navigationRegistration = null;
await ClosePreviewDialogAsync();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -0,0 +1,26 @@
@using WebApp.Services
@for (var i = 0; i < Pages.Count; i++)
{
var page = Pages[i];
var isLast = i == Pages.Count - 1;
var pageClass = NewPagePerRecord && !isLast
? "note-print-page pagebreak"
: "note-print-page";
<MudContainer Class="@pageClass" Style="@PrintPageStyle">
<div class="markdown-content">
@((MarkupString)page.Html)
</div>
</MudContainer>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> Pages { get; set; } = [];
[Parameter]
public bool NewPagePerRecord { get; set; } = true;
[Parameter]
public string PrintPageStyle { get; set; } = string.Empty;
}
@@ -0,0 +1,37 @@
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_name"
Label="Preset name"
Variant="Variant.Outlined"
Immediate="true"
MaxLength="100"
Autofocus="true" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Save"
Disabled="@string.IsNullOrWhiteSpace(_name)">
Save
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
private string _name = string.Empty;
private void Save()
{
var name = _name.Trim();
if (string.IsNullOrWhiteSpace(name))
return;
MudDialog.Close(DialogResult.Ok(name));
}
private void Cancel() => MudDialog.Cancel();
}
@@ -0,0 +1,60 @@
@using WebApp.Services
<MudDialog Class="no-print">
<DialogContent>
@if (Pages.Count == 0)
{
<MudText Class="mud-text-secondary">No matching records for these filters.</MudText>
}
else
{
@if (Pages.Count > 75)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
This preview has @Pages.Count pages. Printing a large set can be slow.
</MudAlert>
}
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">
@Pages.Count page@(Pages.Count == 1 ? "" : "s")
</MudText>
<PrintPageStack Pages="Pages"
NewPagePerRecord="NewPagePerRecord"
PrintPageStyle="@PrintPageStyle" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Print"
OnClick="PrintAsync"
Disabled="@(Pages.Count == 0)">
Print
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public IReadOnlyList<NotePrintPage> Pages { get; set; } = [];
[Parameter]
public bool NewPagePerRecord { get; set; } = true;
[Parameter]
public string PrintPageStyle { get; set; } = string.Empty;
[Parameter]
public EventCallback OnPrint { get; set; }
private async Task PrintAsync()
{
if (OnPrint.HasDelegate)
await OnPrint.InvokeAsync();
}
private void Close() => MudDialog.Close();
}
@@ -0,0 +1,105 @@
@using Core.Printing
@inject ClipboardService ClipboardService
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_search"
Label="Search tokens"
Variant="Variant.Outlined"
Immediate="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Class="mb-3" />
@if (!HasAnyMatches)
{
<MudText Class="mud-text-secondary">No tokens match this search.</MudText>
}
else
{
@foreach (var group in TokenGroups)
{
var tokens = Visible(group.Tokens);
if (tokens.Count == 0)
continue;
<MudText Typo="Typo.caption" Class="mud-text-secondary">@group.Label</MudText>
<div class="mb-2">
<TokenChips Tokens="tokens" Insert="Insert" Copy="Copy" />
</div>
}
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public PrintEntityType EntityType { get; set; }
[Parameter]
public IReadOnlyList<string> ImportedFieldNames { get; set; } = [];
[Parameter]
public EventCallback<string> OnInsert { get; set; }
private string _search = string.Empty;
private IEnumerable<(string Label, IReadOnlyList<string> Tokens)> TokenGroups
{
get
{
yield return ("Layout", PrintFieldCatalog.Layout);
yield return ("Chapter", PrintFieldCatalog.Chapter);
yield return (EntityType.ToString(), PrintFieldCatalog.EntityTokens(EntityType));
if (EntityType == PrintEntityType.Student)
yield return ("Event ranks", PrintFieldCatalog.StudentRanks);
if (EntityType == PrintEntityType.Student && ImportedFieldNames.Count > 0)
yield return ("Additional fields", ImportedFieldNames);
}
}
private bool HasAnyMatches =>
TokenGroups.Any(group => Visible(group.Tokens).Count > 0);
private IReadOnlyList<string> Visible(IReadOnlyList<string> tokens) =>
string.IsNullOrWhiteSpace(_search)
? tokens
: [.. tokens.Where(Matches)];
private bool Matches(string token) =>
string.IsNullOrWhiteSpace(_search)
|| token.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase);
private async Task Insert(string token)
{
if (OnInsert.HasDelegate)
await OnInsert.InvokeAsync(token);
}
private async Task Copy(string token)
{
try
{
await ClipboardService.WriteTextAsync("{{" + token + "}}");
Snackbar.Add("Copied {{" + token + "}}", Severity.Info);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error);
}
}
private void Close() => MudDialog.Close();
}
@@ -0,0 +1,27 @@
@foreach (var token in Tokens)
{
var name = token;
<span class="d-inline-flex align-center mr-1 mb-1">
<MudChip T="string"
Size="Size.Small"
OnClick="() => Insert.InvokeAsync(name)">
{{@name}}
</MudChip>
<MudTooltip Text="Copy">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="() => Copy.InvokeAsync(name)" />
</MudTooltip>
</span>
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<string> Tokens { get; set; } = [];
[Parameter]
public EventCallback<string> Insert { get; set; }
[Parameter]
public EventCallback<string> Copy { get; set; }
}
@@ -0,0 +1,104 @@
@using Core.Services
@using PSC.Blazor.Components.MarkdownEditor
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject MarkdownTablePasteService MarkdownTablePasteService
@inject ISnackbar Snackbar
<MudText Typo="Typo.h5" Class="mb-4">Notes</MudText>
@if (_isLoading)
{
<MudProgressLinear Indeterminate="true" />
}
else if (ReadOnly)
{
@if (string.IsNullOrWhiteSpace(_content))
{
<MudText Class="mud-text-secondary">No notes yet.</MudText>
}
else
{
@((MarkupString)MarkdownHelper.ToHtml(_content))
}
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-2">Markdown is supported. Additional fields appear in a table and can be edited.</MudText>
<MarkdownEditor Value="@_content"
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..."
AutoSaveEnabled="false"
NativeSpellChecker="false" />
}
@code {
[Parameter]
public int StudentId { get; set; }
[Parameter]
public bool ReadOnly { get; set; }
private string _content = string.Empty;
private int? _noteId;
private int _loadedStudentId;
private bool _isLoading = true;
private bool _pasteInitialized;
protected override async Task OnParametersSetAsync()
{
if (StudentId <= 0 || _loadedStudentId == StudentId)
return;
_isLoading = true;
try
{
var note = await NotesService.GetStudentNoteAsync(StudentId);
_noteId = note?.Id;
_content = note?.Content ?? string.Empty;
_loadedStudentId = StudentId;
}
finally
{
_isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ReadOnly || _pasteInitialized)
return;
await Task.Delay(150);
await MarkdownTablePasteService.InitializeAsync();
_pasteInitialized = true;
}
public async Task SaveAsync()
{
if (StudentId <= 0)
return;
if (_noteId is null)
{
if (string.IsNullOrWhiteSpace(_content))
return;
var created = await NotesService.CreateNoteAsync(new Note
{
Title = NoteNamingService.GetStudentNoteTitle(StudentId),
Content = _content
});
_noteId = created.Id;
return;
}
var existing = await NotesService.GetNoteAsync(_noteId.Value);
if (existing is null)
return;
if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal))
return;
existing.Content = _content;
await NotesService.UpdateNoteAsync(existing);
}
}
@@ -82,6 +82,10 @@
</MudGrid>
</MudPaper>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
</MudPaper>
@code {
private Student? student;
@@ -52,6 +52,11 @@
</MudSelect>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
</MudPaper>
</MudItem>
</MudGrid>
</EditForm>
@@ -73,6 +78,7 @@
private FormChangeTracker? _formChangeTracker;
private EditContext? _editContext;
private List<string> _validationErrors = new();
private StudentNotePanel? _notePanel;
protected override async Task OnInitializedAsync()
{
@@ -120,6 +126,8 @@
try
{
await Context.SaveChangesAsync();
if (_notePanel is not null)
await _notePanel.SaveAsync();
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
@@ -2,17 +2,40 @@
@attribute [Authorize]
@implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject INotesService NotesService
@inject IConfiguration Configuration
@inject IJSRuntime JSRuntime
@using Core.Notes
@using Core.Parsers
@using WebApp.Services
<PageHeader Title="Students">
<ActionButtons>
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudButtonGroup Variant="Variant.Outlined">
<MudTooltip Text="Add new students from CSV. Existing names are skipped; leftover columns merge into student notes.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/students/import">Import</MudButton>
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.ArrowDropDown"
AriaLabel="More import actions"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight">
<MudMenuItem Icon="@Icons.Material.Filled.Download"
OnClick="DownloadStudentImportTemplate">
Download CSV template
</MudMenuItem>
</MudMenu>
</MudButtonGroup>
</AuthorizeView>
<MudTooltip Text="Event Rankings">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
</MudTooltip>
@@ -24,6 +47,7 @@
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudDataGrid T="Student"
@key="NoteFieldColumnsKey"
ServerData="ServerReload"
@ref="_dataGrid"
Filterable="true"
@@ -65,6 +89,15 @@
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
</CellTemplate>
</PropertyColumn>
@foreach (var field in _noteFieldColumns)
{
var fieldName = field;
<TemplateColumn Title="@fieldName" Sortable="false" Filterable="false">
<CellTemplate>
@GetNoteField(context.Item.Id, fieldName)
</CellTemplate>
</TemplateColumn>
}
</Columns>
<PagerContent>
<MudDataGridPager T="Student"></MudDataGridPager>
@@ -77,12 +110,20 @@
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
private List<string> _noteFieldColumns = [];
private Dictionary<int, string?> _noteContentByStudentId = [];
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override void OnParametersSet()
{
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
}
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
{
if (_isDisposed)
@@ -104,6 +145,9 @@
var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
var notes = await NotesService.GetStudentNotesAsync(pagedData.Select(s => s.Id));
_noteContentByStudentId = notes.ToDictionary(k => k.Key, v => v.Value.Content);
return new GridData<Student>
{
TotalItems = totalItems,
@@ -160,6 +204,7 @@
return;
}
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
Context.Students.Remove(studentToDelete);
await Context.SaveChangesAsync(cancellationToken);
@@ -203,4 +248,42 @@
}
await ValueTask.CompletedTask;
}
private async Task DownloadStudentImportTemplate()
{
if (_isDisposed)
return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var fromNotes = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
var leftover = _noteFieldColumns
.Concat(fromNotes)
.Distinct(StringComparer.OrdinalIgnoreCase);
var csv = StudentImportCsvTemplate.Build(leftover);
byte[] bytes = [..System.Text.Encoding.UTF8.GetPreamble(), ..System.Text.Encoding.UTF8.GetBytes(csv)];
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("tsaDownload.fromBase64", "student-import-template.csv", "text/csv;charset=utf-8", base64);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not download template: {ex.Message}", Severity.Error);
}
}
private string GetNoteField(int studentId, string fieldName)
{
if (!_noteContentByStudentId.TryGetValue(studentId, out var content))
return string.Empty;
return ImportedFieldsTable.GetFieldValue(content, fieldName) ?? string.Empty;
}
}
@@ -0,0 +1,301 @@
@page "/students/import"
@page "/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Core.Services
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@inject AppDbContext Context
@inject IStudentNotesImportService NotesImportService
@inject IStudentNotesImportSaveService NotesSaveService
@inject INotesService NotesService
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<StudentImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Students"
Description="Add new students from CSV. Existing first+last names are skipped; leftover columns merge into student notes."
ShowBackButton="true"
BackButtonUrl="/students" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Student Name</code>, <code>Grade</code>, <code>TSA year</code>.
Optional IDs are saved on new students. Every other column is merged into that student's notes.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_students is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_students.Length student(s) parsed.
@_newStudentCount new, @_existingStudentCount already in the database.
</MudAlert>
@if (_leftoverFieldNames.Count > 0)
{
<MudText Typo="Typo.body2">Leftover note fields: @string.Join(", ", _leftoverFieldNames)</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">No leftover note fields in this file.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _students.Length == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private Student[]? _students;
private List<string> _leftoverFieldNames = [];
private int _newStudentCount;
private int _existingStudentCount;
private string? _parseError;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading student CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_students = new StudentParser(reader).Parse();
_leftoverFieldNames = PeekLeftoverFieldNames(_fileBytes);
var existingNames = await Context.Students
.AsNoTracking()
.Select(s => new { s.FirstName, s.LastName })
.ToListAsync(token);
var existingSet = existingNames
.Select(s => (s.FirstName, s.LastName))
.ToHashSet();
_existingStudentCount = _students.Count(s => existingSet.Contains((s.FirstName, s.LastName)));
_newStudentCount = _students.Length - _existingStudentCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing student CSV");
_students = null;
_leftoverFieldNames = [];
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_students is null || _fileBytes is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var student in _students)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Students
.FirstOrDefaultAsync(e => e.FirstName == student.FirstName && e.LastName == student.LastName, token);
if (exists != null)
continue;
await Context.Students.AddAsync(student, token);
added++;
}
await Context.SaveChangesAsync(token);
var notesCreated = 0;
var notesUpdated = 0;
if (_leftoverFieldNames.Count > 0)
{
var students = await Context.Students
.AsNoTracking()
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(token);
var notes = await NotesService.GetStudentNotesAsync(students.Select(s => s.Id));
var existing = notes.ToDictionary(k => k.Key, v => v.Value.Content);
await using var csvStream = new MemoryStream(_fileBytes, writable: false);
var parseResult = NotesImportService.Parse(csvStream, students, existing);
if (parseResult.IsSuccess && parseResult.StudentsWithChanges > 0)
{
var saveResult = await NotesSaveService.SaveAsync(parseResult, token);
notesCreated = saveResult.NotesCreated;
notesUpdated = saveResult.NotesUpdated;
}
}
if (_isDisposed)
return;
Snackbar.Add(
$"Added {added} student(s). Notes created {notesCreated}, updated {notesUpdated}.",
Severity.Success);
NavigationManager.NavigateTo("/students");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported students");
if (!_isDisposed)
Snackbar.Add($"Error saving students: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_students = null;
_leftoverFieldNames = [];
_newStudentCount = 0;
_existingStudentCount = 0;
_parseError = null;
}
private static List<string> PeekLeftoverFieldNames(byte[] csvBytes)
{
using var reader = new StreamReader(new MemoryStream(csvBytes));
using var parser = new StudentNotesFieldParser(reader);
return parser.PeekLeftoverFieldNames();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
+104 -6
View File
@@ -1,5 +1,6 @@
@page "/settings/chapter"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@@ -7,12 +8,13 @@
@using Core.Models
@inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter
@inject INotesService NotesService
@rendermode InteractiveServer
<PageHeader
Title="Chapter Settings"
Description="Configure chapter information. Changes take effect on next application restart." />
Description="Configure chapter information. Student index columns apply the next time you open Students. Printouts that cache chapter name or year may still need a restart." />
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
@@ -90,6 +92,41 @@
</MudGrid>
</MudPaper>
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Field names from each student's additional-fields table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line).
</MudText>
@if (_availableFields.Count > 0)
{
<MudText Typo="Typo.subtitle2" Class="mb-2">Fields in student notes</MudText>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" Class="mb-4">
@foreach (var field in _availableFields)
{
var selected = IsFieldSelected(field);
<MudChip T="string"
Size="Size.Small"
Color="@(selected ? Color.Primary : Color.Default)"
Variant="@(selected ? Variant.Filled : Variant.Outlined)"
OnClick="() => ToggleField(field)">
@field
</MudChip>
}
</MudStack>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mb-3">
No additional fields found in student notes yet.
</MudText>
}
<MudTextField @bind-Value="_noteFieldsText"
Label="Additional field columns"
Variant="Variant.Outlined"
Lines="5"
HelperText="Example: Interview Time" />
</MudPaper>
<MudPaper Class="pa-6">
<MudGrid>
<MudItem xs="12">
@@ -125,14 +162,52 @@
@code {
private Models.ChapterSettings? _settings;
private string _noteFieldsText = string.Empty;
private IReadOnlyList<string> _availableFields = [];
private bool _isSaving;
private string? _statusMessage;
private Severity _statusSeverity = Severity.Success;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings();
_cancellationTokenSource = new CancellationTokenSource();
_settings = Models.ChapterSettings.FromConfiguration(Configuration);
_noteFieldsText = string.Join(Environment.NewLine, _settings.StudentIndexNoteFields);
}
protected override async Task OnInitializedAsync()
{
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
_availableFields = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
}
private IReadOnlyList<string> SelectedFields() =>
_noteFieldsText
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
private bool IsFieldSelected(string field) =>
SelectedFields().Any(selected => selected.Equals(field, StringComparison.OrdinalIgnoreCase));
private void ToggleField(string field)
{
var selected = SelectedFields().ToList();
var index = selected.FindIndex(name => name.Equals(field, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
selected.RemoveAt(index);
else
selected.Add(field);
_noteFieldsText = string.Join(Environment.NewLine, selected);
}
private async Task SaveSettings()
@@ -144,18 +219,41 @@
try
{
await ChapterSettingsWriter.WriteAsync(_settings);
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_settings.StudentIndexNoteFields = [.. SelectedFields()];
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
await ChapterSettingsWriter.WriteAsync(_settings, cancellationToken);
if (_isDisposed)
return;
_statusMessage = "Settings saved. Open Students again to see the updated index columns.";
_statusSeverity = Severity.Success;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
if (_isDisposed)
return;
_statusMessage = $"Error saving settings: {ex.Message}";
_statusSeverity = Severity.Error;
}
finally
{
_isSaving = false;
if (!_isDisposed)
_isSaving = false;
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
-115
View File
@@ -1,115 +0,0 @@
@page "/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@inject AppDbContext Context
@rendermode InteractiveServer
<PageTitle>Import Data</PageTitle>
<h1>Import Data</h1>
<h3>Events</h3>
<InputFile OnChange="UploadEvents"></InputFile>
<text>@_events?.Length Events</text>
<button class="btn btn-primary" @onclick="SaveEvents">Save to Database</button>
<br/>
<h3>Students</h3>
<InputFile OnChange="UploadStudents"></InputFile>
<text>@_students?.Length Students</text>
<button class="btn btn-primary" @onclick="SaveStudents">Save to Database</button>
@code {
private EventDefinition[]? _events;
private Student[]? _students;
async Task UploadEvents(InputFileChangeEventArgs arg)
{
await GetStreamReaderFromInputFile(arg, reader =>
{
var eventDefinitionParser = new EventDefinitionParser(reader);
_events = eventDefinitionParser.Parse();
});
}
async Task SaveEvents()
{
if (_events == null)
return;
foreach (var evt in _events)
{
// check if it already exists
var exists
= await Context.Events
.FirstOrDefaultAsync(e => e.Name == evt.Name);
if (exists != null)
continue;
await Context.Events.AddAsync(evt);
}
await Context.SaveChangesAsync();
}
async Task UploadStudents(InputFileChangeEventArgs arg)
{
await GetStreamReaderFromInputFile(arg, reader =>
{
var studentParser = new StudentParser(reader);
_students = studentParser.Parse();
});
}
async Task SaveStudents()
{
if (_students == null)
return;
try
{
foreach (var student in _students)
{
// check if it already exists
var exists
= await Context.Students
.FirstOrDefaultAsync(e
=> e.FirstName == student.FirstName
&& e.LastName == student.LastName);
if (exists != null)
continue;
await Context.Students.AddAsync(student);
}
await Context.SaveChangesAsync();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
static async Task GetStreamReaderFromInputFile(InputFileChangeEventArgs arg, Action<StreamReader> f)
{
StreamReader? streamReader = null;
try
{
var browserFile = arg.File;
await using var fs = browserFile.OpenReadStream();
await using var ms = new MemoryStream();
await fs.CopyToAsync(ms);
ms.Seek(0,0);
streamReader = new StreamReader(ms);
f(streamReader);
}
catch
{
streamReader?.Dispose();
throw;
}
}
}
+7 -8
View File
@@ -1,15 +1,14 @@
@using WebApp.Models
@using Core.Printing
@using WebApp.Models
<MudPaper>
<h3>Legend</h3>
<MudContainer>
<ul>
<li>@AppIcons.LevelOfEffortIcon(1) - Level of Effort </li>
<li>@AppIcons.IndividualEvent - Individual Event </li>
<li>@AppIcons.RegionalEvent - Regional </li>
<li>@AppIcons.OnSiteActivity - On-site Activity</li>
<li>@AppIcons.PresubmissionEvent - Pre-submission</li>
<li>@AppIcons.PresentationEvent - Interview Or Presentation</li>
@foreach (var mark in EventAttributeMarks.LegendItems)
{
<li>@mark.Symbol - @mark.Label</li>
}
</ul>
</MudContainer>
</MudPaper>
</MudPaper>
@@ -80,7 +80,7 @@
{
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
}
catch (Exception ex)
catch (Exception)
{
// Error handling - could show snackbar if we had access
}
+13 -12
View File
@@ -59,7 +59,7 @@
var isExpanded = GetNoteExpanded(noteId);
<MudExpansionPanel @key="@($"note-{noteId}")"
Icon="@Icons.Material.Filled.Note"
IsExpanded="@isExpanded"
Expanded="@isExpanded"
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
<TitleContent>
@@ -71,15 +71,16 @@
</MudText>
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
{
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
OnClick:StopPropagation="true"
Variant="Variant.Text"
Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))"
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
Class="flex-shrink-0" />
<div @onclick:stopPropagation="true" class="flex-shrink-0">
<MudTooltip Text="@(note.IsPinned ? "Unpin note" : "Pin note")">
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
Variant="Variant.Text"
Size="Size.Small"
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
Disabled="@(IsPinDisabled(note))" />
</MudTooltip>
</div>
}
</MudStack>
</TitleContent>
@@ -301,7 +302,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadNotes();
}
@@ -327,7 +328,7 @@
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
if (result is { Canceled: false } && !_isDisposed)
{
await LoadNotes();
}
@@ -3,28 +3,26 @@
@inject IDialogService DialogService
@implements IAsyncDisposable
<div @ref="_anchorElement"
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
<div @onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
@onmouseleave="@(() => _popoverOpen = false)"
style="display: inline-block;">
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
Size="@Size"
Color="@ButtonColor"
Tooltip="@TooltipText"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@if (!string.IsNullOrEmpty(ButtonText))
{
@ButtonText
}
</MudButton>
</div>
<MudPopover @bind-Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter"
Elevation="8"
Anchor="@_anchorElement">
style="display: inline-block; position: relative;">
<MudTooltip Text="@TooltipText">
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
Size="@Size"
Color="@ButtonColor"
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
@if (!string.IsNullOrEmpty(ButtonText))
{
@ButtonText
}
</MudButton>
</MudTooltip>
<MudPopover Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter"
Elevation="8">
<ChildContent>
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
{
@@ -41,7 +39,8 @@
</MudPaper>
}
</ChildContent>
</MudPopover>
</MudPopover>
</div>
@code {
[Parameter]
@@ -68,7 +67,6 @@
private int _noteId = 0;
private string _noteContent = string.Empty;
private bool _popoverOpen = false;
private ElementReference _anchorElement;
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
@@ -30,6 +30,7 @@
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
<MudNavLink Href="/print" Icon="@Icons.Material.Filled.Print">Page printer</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Administrator">
+24 -91
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Printing;
using MudBlazor;
namespace WebApp.Models
@@ -14,50 +15,26 @@ namespace WebApp.Models
public static string Captain = Icons.Material.Filled.Star;
public static string Registration = Icons.Material.Filled.AppRegistration;
public static string EventCalendar = Icons.Material.Filled.Event;
public static string LevelOfEffortIcon(int? loe)
{
return loe switch
public static string LevelOfEffortIcon(int? loe) =>
loe switch
{
1 => "○",
2 => "◐",
3 => "⬤",
1 => EventAttributeMarks.LevelOfEffort1,
2 => EventAttributeMarks.LevelOfEffort2,
3 => EventAttributeMarks.LevelOfEffort3,
_ => Icons.Material.Filled.QuestionMark
};
}
/*https://unicodeplus.com/search*/
public static string OnSiteActivity = "ⓐ";
public static string RegionalEvent = "ⓡ";
public static string IndividualEvent = "ⓘ";
public static string PresubmissionEvent = "ⓟ";
public static string PresentationEvent = "";
public static string OnSiteActivity => EventAttributeMarks.OnSite;
public static string RegionalEvent => EventAttributeMarks.Regional;
public static string IndividualEvent => EventAttributeMarks.Individual;
public static string PresubmissionEvent => EventAttributeMarks.Presubmission;
public static string PresentationEvent => "";
// Tooltip mapping for icon unicode characters
public static Dictionary<string, string> IconTooltips => new()
{
{ OnSiteActivity, "On-Site Activity" },
{ RegionalEvent, "Regional Event" },
{ IndividualEvent, "Individual Event" },
{ PresubmissionEvent, "Presubmission" },
{ PresentationEvent, "Presentation/Interview" },
{ "○", "Level of Effort: 1" },
{ "◐", "Level of Effort: 2" },
{ "●", "Level of Effort: 3" }
};
public static IReadOnlyDictionary<string, string> IconTooltips { get; } =
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal);
// Color mapping for icon unicode characters
public static Dictionary<string, string> IconColors => new()
{
{ OnSiteActivity, "#ff9800" }, // Orange
{ RegionalEvent, "#2196f3" }, // Blue
{ IndividualEvent, "#9c27b0" }, // Purple
{ PresubmissionEvent, "#4caf50" }, // Green
{ PresentationEvent, "#f44336" }, // Red
{ "○", "#757575" }, // Gray
{ "◐", "#616161" }, // Darker Gray
{ "●", "#424242" } // Even Darker Gray
};
public static IReadOnlyDictionary<string, string> IconColors { get; } =
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Color, StringComparer.Ordinal);
public static string EventEffort(EventDefinition eventDefinition)
{
@@ -96,63 +73,19 @@ namespace WebApp.Models
};
}
public static string RankedEventColor(int rank)
{
return rank switch
{
1 => "#dd7e6b",
2 => "#ea9999",
3 => "#f9cb9c",
4 => "#ffe599",
5 => "#fff2cc",
6 => "#fffaea",
7 => "#fffefa",
8 => "#fffefc",
9 => "#fffffd",
10 => "#fffffe",
_ => "#ddd"
};
}
public static string RankedEventColor(int rank) => EventRankLegend.ColorHex(rank);
public static string GetOrdinal(int num)
{
if (num <= 0) return num.ToString();
switch (num % 100)
{
case 11:
case 12:
case 13:
return num + "th";
}
switch (num % 10)
{
case 1:
return num + "st";
case 2:
return num + "nd";
case 3:
return num + "rd";
default:
return num + "th";
}
}
public static string GetOrdinal(int num) => EventRankLegend.Ordinal(num);
public static string GetOrdinalSuperscript(int number)
{
var suffix = number switch
{
11 or 12 or 13 => "th",
_ => (number % 10) switch
{
1 => "st",
2 => "nd",
3 => "rd",
_ => "th"
}
};
return $"{number}<sup>{suffix}</sup>";
var ordinal = EventRankLegend.Ordinal(number);
var suffixAt = 0;
while (suffixAt < ordinal.Length && (char.IsDigit(ordinal[suffixAt]) || ordinal[suffixAt] == '-'))
suffixAt++;
return suffixAt is 0 || suffixAt == ordinal.Length
? ordinal
: $"{ordinal[..suffixAt]}<sup>{ordinal[suffixAt..]}</sup>";
}
/// <summary>
+24
View File
@@ -1,4 +1,6 @@
using Core.Models;
using Core.Notes;
using Microsoft.Extensions.Configuration;
namespace WebApp.Models;
@@ -51,4 +53,26 @@ public class ChapterSettings
/// School level for the chapter (null = import both MS and HS events)
/// </summary>
public SchoolLevel? SchoolLevel { get; set; }
/// <summary>
/// Field names from student note <c>## Additional fields</c> tables to show as Students index columns.
/// </summary>
public List<string> StudentIndexNoteFields { get; set; } = [.. StudentNoteFieldDefaults.IndexColumns];
public static ChapterSettings FromConfiguration(IConfiguration configuration)
{
var settings = configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
?? new ChapterSettings();
settings.StudentIndexNoteFields = ReadIndexNoteFields(configuration);
return settings;
}
public static List<string> ReadIndexNoteFields(IConfiguration configuration)
{
var fields = configuration.GetSection("ChapterSettings:StudentIndexNoteFields").Get<List<string>>();
if (fields is null)
return [.. StudentNoteFieldDefaults.IndexColumns];
return [.. fields.Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim())];
}
}
+14 -5
View File
@@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using Serilog;
using System.Text.Json;
using System.Text.Json.Nodes;
using Core.Notes;
using VisNetwork.Blazor;
using WebApp;
using WebApp.Authentication;
@@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath))
var baseConfig = File.ReadAllText(baseAppSettingsPath);
var baseDoc = JsonDocument.Parse(baseConfig);
var templateSettings = new Dictionary<string, object?>();
JsonObject templateSettings = [];
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
{
templateSettings["ChapterSettings"] = JsonSerializer.Deserialize<object>(chapterSettings.GetRawText());
var chapterObj = JsonNode.Parse(chapterSettings.GetRawText()) as JsonObject ?? [];
if (chapterObj["StudentIndexNoteFields"] is null)
chapterObj["StudentIndexNoteFields"] = JsonSerializer.SerializeToNode(StudentNoteFieldDefaults.IndexColumns);
templateSettings["ChapterSettings"] = chapterObj;
}
if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings))
{
templateSettings["ValidationSettings"] = JsonSerializer.Deserialize<object>(validationSettings.GetRawText());
templateSettings["ValidationSettings"] = JsonNode.Parse(validationSettings.GetRawText());
}
if (templateSettings.Any())
if (templateSettings.Count > 0)
{
var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions
var json = templateSettings.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
});
@@ -206,6 +211,10 @@ builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Servic
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
builder.Services.AddScoped<WebApp.Services.INotePrintService, WebApp.Services.NotePrintService>();
builder.Services.AddScoped<WebApp.Services.IPrintPresetService, WebApp.Services.PrintPresetService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
+23 -11
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using WebApp.Models;
namespace WebApp.Services;
@@ -8,6 +9,11 @@ namespace WebApp.Services;
/// </summary>
public class ChapterSettingsWriter : IChapterSettingsWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly ILogger<ChapterSettingsWriter> _logger;
@@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter
var appSettingsPath = GetAppSettingsPath();
var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
Dictionary<string, object?> root;
JsonObject root;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? [];
root = JsonNode.Parse(existingJson) as JsonObject ?? [];
}
else
{
root = [];
}
root["ChapterSettings"] = settings;
var incoming = JsonSerializer.SerializeToNode(settings, JsonOptions) as JsonObject ?? [];
if (root["ChapterSettings"] is JsonObject existing)
{
foreach (var property in incoming)
existing[property.Key] = property.Value?.DeepClone();
}
else
{
root["ChapterSettings"] = incoming;
}
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(root, options);
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken);
if (_configuration is IConfigurationRoot configurationRoot)
configurationRoot.Reload();
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
}
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
{
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
?? new ChapterSettings();
var settings = ChapterSettings.FromConfiguration(_configuration);
settings.CompetitionYear = competitionYear;
await WriteAsync(settings, cancellationToken);
}
+28
View File
@@ -0,0 +1,28 @@
using Core.Printing;
namespace WebApp.Services;
public class NotePrintRequest
{
public required PrintEntityType EntityType { get; init; }
public required string TemplateMarkdown { get; init; }
public required PrintPresetFilters Filters { get; init; }
public required IReadOnlyList<string> ImportedFieldCatalog { get; init; }
}
public class NotePrintPage
{
public required string DisplayName { get; init; }
public required string Html { get; init; }
}
public interface INotePrintService
{
Task<IReadOnlyList<NotePrintPage>> PreviewAsync(
NotePrintRequest request,
CancellationToken cancellationToken = default);
}
+20
View File
@@ -22,6 +22,26 @@ public interface INotesService
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
/// Gets the system note for a student, or null if none exists.
/// </summary>
Task<Note?> GetStudentNoteAsync(int studentId);
/// <summary>
/// Gets system notes for the given student ids, keyed by student id.
/// </summary>
Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds);
/// <summary>
/// Soft-deletes system notes for the given student ids.
/// </summary>
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
/// <summary>
/// Distinct Field names from student note <c>## Additional fields</c> tables.
/// </summary>
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all history entries for a note.
/// </summary>
+18
View File
@@ -0,0 +1,18 @@
using Core.Entities;
namespace WebApp.Services;
public interface IPrintPresetService
{
Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default);
Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default);
Task<bool> NameExistsAsync(string name, int? excludeId = null, CancellationToken cancellationToken = default);
Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,22 @@
using Core.Models;
namespace WebApp.Services;
/// <summary>
/// Persists merged student field notes.
/// </summary>
public interface IStudentNotesImportSaveService
{
Task<StudentNotesImportSaveResult> SaveAsync(
StudentNotesImportResult parseResult,
CancellationToken cancellationToken = default);
}
public class StudentNotesImportSaveResult
{
public int NotesCreated { get; set; }
public int NotesUpdated { get; set; }
public int StudentsUnchanged { get; set; }
}
@@ -38,4 +38,55 @@ public class MarkdownTablePasteService
_logger.LogError(ex, "Unexpected error initializing paste-markdown for editor {EditorId}", editorId ?? "unknown");
}
}
public async Task<string?> GetValueAsync(string editorId)
{
try
{
return await _jsRuntime.InvokeAsync<string?>("markdownTablePaste.getValue", editorId);
}
catch (JSDisconnectedException)
{
return null;
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to read markdown editor {EditorId}", editorId);
return null;
}
}
public async Task<bool> SetValueAsync(string editorId, string text)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("markdownTablePaste.setValue", editorId, text);
}
catch (JSDisconnectedException)
{
return false;
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to set markdown editor {EditorId}", editorId);
return false;
}
}
public async Task<bool> InsertAtCursorAsync(string editorId, string text)
{
try
{
return await _jsRuntime.InvokeAsync<bool>("markdownTablePaste.insertAtCursor", editorId, text);
}
catch (JSDisconnectedException)
{
return false;
}
catch (JSException ex)
{
_logger.LogWarning(ex, "Failed to insert into markdown editor {EditorId}", editorId);
return false;
}
}
}
+346
View File
@@ -0,0 +1,346 @@
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
.Include(s => s.EventRankings)
.ThenInclude(r => r.EventDefinition)
.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 FinishPreview(
request,
template,
chapter,
rows.Count == 1 ? "1 student" : $"{rows.Count} students",
rows.Select(row => new RecordMerge(
row.Student.LastNameFirstName,
row.Imported,
StudentTokens(row.Student),
StudentHtmlFragments(row.Student))));
}
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 FinishPreview(
request,
template,
chapter,
teams.Count == 1 ? "1 team" : $"{teams.Count} teams",
teams.Select(team => new RecordMerge(team.ToString(), null, TeamTokens(team), null)));
}
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);
var rankings = await _context.StudentEventRanking
.AsNoTracking()
.Include(r => r.Student)
.Include(r => r.EventDefinition)
.ToListAsync(cancellationToken);
var rankingsByEventId = rankings
.GroupBy(r => r.EventDefinition.Id)
.ToDictionary(g => g.Key, g => g.ToList());
return FinishPreview(
request,
template,
chapter,
events.Count == 1 ? "1 event" : $"{events.Count} events",
events.Select(evt => new RecordMerge(
evt.Name,
null,
EventTokens(evt),
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id)))));
}
private readonly record struct RecordMerge(
string DisplayName,
Dictionary<string, string?>? Imported,
Dictionary<string, string?> Entity,
IReadOnlyDictionary<string, string>? HtmlFragments);
private static IReadOnlyList<NotePrintPage> FinishPreview(
NotePrintRequest request,
string template,
Dictionary<string, string?> chapter,
string combinedDisplayName,
IEnumerable<RecordMerge> records)
{
var list = records.ToList();
if (list.Count == 0)
return [];
if (!request.Filters.NewPagePerRecord
&& MarkdownTableStencil.TryParse(template, out var stencil)
&& stencil is not null)
{
var chapterMap = PrintTokenMap.Build(null, null, chapter);
var prefix = NoteTemplateMerger.Merge(stencil.Prefix, chapterMap);
var suffix = NoteTemplateMerger.Merge(stencil.Suffix, chapterMap);
var bodies = list.Select(record =>
NoteTemplateMerger.Merge(
stencil.Body,
PrintTokenMap.Build(record.Imported, record.Entity, chapter)));
return
[
new NotePrintPage
{
DisplayName = combinedDisplayName,
Html = NoteTemplateMerger.ApplyLayout(
MarkdownHelper.ToHtml(stencil.Stitch(prefix, bodies, suffix)))
}
];
}
return
[
.. list.Select(record => MergePage(
record.DisplayName,
template,
record.Imported,
record.Entity,
chapter,
record.HtmlFragments))
];
}
private static NotePrintPage MergePage(
string displayName,
string template,
Dictionary<string, string?>? imported,
Dictionary<string, string?> entity,
Dictionary<string, string?> chapter,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
var map = PrintTokenMap.Build(imported, entity, chapter);
return new NotePrintPage
{
DisplayName = displayName,
Html = NoteTemplateMerger.ApplyLayout(
MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)),
htmlFragments)
};
}
private static Dictionary<string, string> StudentHtmlFragments(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings)
};
private static Dictionary<string, string> EventHtmlFragments(IReadOnlyList<StudentEventRanking>? rankings) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings)
};
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)
{
var tokens = new Dictionary<string, string?>(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()
};
foreach (var (key, value) in StudentRankTokens.FromRankings(student.EventRankings))
tokens[key] = value;
return tokens;
}
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,
["NationalEligibility"] = evt?.Eligibility,
["Eligibility"] = evt?.Eligibility,
["RegionalTeamCount"] = evt?.ChapterEligibilityCountRegionals.ToString(),
["StateTeamCount"] = evt?.ChapterEligibilityCountState.ToString(),
["Description"] = evt?.Description,
["Theme"] = evt?.Theme,
["EventAttributes"] = EventAttributeMarks.For(evt)
};
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;
}
}
+85 -5
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Notes;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
@@ -46,8 +47,9 @@ public class NotesService : INotesService
}
return await query
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
@@ -67,6 +69,84 @@ public class NotesService : INotesService
.FirstOrDefaultAsync();
}
public async Task<Note?> GetStudentNoteAsync(int studentId)
{
var title = _noteNamingService.GetStudentNoteTitle(studentId);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == title && !n.IsDeleted)
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<int, Note>();
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.AsNoTracking()
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync();
Dictionary<int, Note> byStudentId = [];
foreach (var note in notes)
{
if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId))
byStudentId[studentId] = note;
}
return byStudentId;
}
public async Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return;
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync(cancellationToken);
if (notes.Count == 0)
return;
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
foreach (var note in notes)
{
_context.NoteHistories.Add(new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Soft Deleted"
});
note.IsDeleted = true;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Soft-deleted {Count} student note(s)", notes.Count);
}
public async Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default)
{
var contents = await _context.Notes
.AsNoTracking()
.Where(n => !n.IsDeleted && n.Title != null && n.Title.StartsWith("#Student:"))
.Select(n => n.Content)
.ToListAsync(cancellationToken);
return ImportedFieldsTable.DistinctFieldNames(contents);
}
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{
return await _context.NoteHistories
@@ -253,9 +333,9 @@ public class NotesService : INotesService
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsDeleted)
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
+95
View File
@@ -0,0 +1,95 @@
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()
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
public async Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.PrintPresets
.AsNoTracking()
.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.TemplateMarkdown ??= string.Empty;
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.TemplateMarkdown = preset.TemplateMarkdown ?? string.Empty;
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);
}
}
@@ -0,0 +1,64 @@
using Core.Models;
using Core.Services;
namespace WebApp.Services;
/// <summary>
/// Creates or updates #Student:{id} notes when additional fields actually change.
/// </summary>
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
{
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
private readonly ILogger<StudentNotesImportSaveService> _logger;
public StudentNotesImportSaveService(
INotesService notesService,
INoteNamingService noteNamingService,
ILogger<StudentNotesImportSaveService> logger)
{
_notesService = notesService;
_noteNamingService = noteNamingService;
_logger = logger;
}
/// <inheritdoc />
public async Task<StudentNotesImportSaveResult> SaveAsync(
StudentNotesImportResult parseResult,
CancellationToken cancellationToken = default)
{
var result = new StudentNotesImportSaveResult();
var existingNotes = await _notesService.GetStudentNotesAsync(
parseResult.Matches.Select(m => m.Student.Id));
var actions = StudentNotesImportPlan.Create(parseResult, existingNotes.Keys.ToHashSet());
result.StudentsUnchanged = parseResult.Matches.Count - actions.Count;
foreach (var action in actions)
{
cancellationToken.ThrowIfCancellationRequested();
if (action.Kind == StudentNotePersistKind.Update
&& existingNotes.TryGetValue(action.StudentId, out var existing))
{
existing.Content = action.Markdown;
await _notesService.UpdateNoteAsync(existing);
result.NotesUpdated++;
continue;
}
await _notesService.CreateNoteAsync(new Core.Entities.Note
{
Title = _noteNamingService.GetStudentNoteTitle(action.StudentId),
Content = action.Markdown
});
result.NotesCreated++;
}
_logger.LogInformation(
"Student notes import saved. Created={Created}, Updated={Updated}, Unchanged={Unchanged}",
result.NotesCreated,
result.NotesUpdated,
result.StudentsUnchanged);
return result;
}
}
+5
View File
@@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService
private readonly AppDbContext _context;
private readonly IDatabaseBackupService _backupService;
private readonly IChapterSettingsWriter _chapterSettingsWriter;
private readonly INotesService _notesService;
private readonly ILogger<YearRolloverService> _logger;
public YearRolloverService(
AppDbContext context,
IDatabaseBackupService backupService,
IChapterSettingsWriter chapterSettingsWriter,
INotesService notesService,
ILogger<YearRolloverService> logger)
{
_context = context;
_backupService = backupService;
_chapterSettingsWriter = chapterSettingsWriter;
_notesService = notesService;
_logger = logger;
}
@@ -107,6 +110,8 @@ public class YearRolloverService : IYearRolloverService
$"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again.");
}
await _notesService.SoftDeleteStudentNotesAsync(removalIds, cancellationToken);
_context.Students.RemoveRange(toRemove);
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
+145 -1
View File
@@ -16,13 +16,70 @@
display: none !important;
}
.print-only {
display: block !important;
}
.mud-overlay,
.mud-dialog-container,
.mud-overlay-dialog {
display: none !important;
}
.nobrk {
break-inside: avoid;
}
.pagebreak {
page-break-after: always;
}
}
.markdown-content,
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content p,
.markdown-content li,
.markdown-content td,
.markdown-content th {
color: #000;
background-color: transparent;
}
.markdown-content h1,
.markdown-content h2 {
border-bottom-color: #ccc;
}
.markdown-content table th,
.markdown-content table td {
border-color: #000;
}
.markdown-content table th {
background-color: #eee;
}
.markdown-content a {
color: #000;
text-decoration: underline;
}
.note-print-page,
.note-print-page .markdown-content {
font-size: var(--print-font-size, 12pt);
}
.print-answer-space {
height: calc(var(--print-answer-lines, 3) * 1.35em);
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{
@@ -42,6 +99,10 @@
.event-rank-4 { background-color: #ffe599; }
.event-rank-5 { background-color: #fff2cc; }
.event-rank-6 { background-color: #fffaea; }
.event-rank-7 { background-color: #fffefa; }
.event-rank-8 { background-color: #fffefc; }
.event-rank-9 { background-color: #fffffd; }
.event-rank-10 { background-color: #fffffe; }
.pre-wrap-text {
@@ -324,6 +385,89 @@
height: auto;
}
.print-only {
display: none;
}
#page-printer-editor .EasyMDEContainer .CodeMirror {
min-height: 16rem;
}
.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-rank-badges {
margin: 0.2em 0 0.5em;
line-height: 1.6;
}
.print-rank-badge {
display: inline-block;
white-space: nowrap;
line-height: 1.2;
}
.print-rank-dot {
display: inline-block;
width: 0.65em;
height: 0.65em;
border-radius: 50%;
flex-shrink: 0;
background: #ddd;
}
.print-event-attrs {
font-family: monospace;
white-space: pre;
}
.print-badge-legend {
margin: 0.4em 0 0.9em;
font-size: 0.85em;
}
.print-attr-legend {
margin-top: 0.25em;
line-height: 1.6;
}
.print-legend-mark {
display: inline-block;
white-space: nowrap;
}
.print-answer-space {
display: block;
height: calc(var(--print-answer-lines, 3) * 1.35em);
overflow: hidden;
margin: 0.35em 0 0.75em;
box-sizing: content-box;
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 1.35em,
#bbb 1.35em,
#bbb calc(1.35em + 1px)
);
}
/* Note color classes - pastel background colors */
.note-color-0 {
background-color: #e3f2fd;
+21
View File
@@ -0,0 +1,21 @@
window.tsaDownload = {
/**
* Triggers a browser file download from a base64 payload.
*/
fromBase64: function (filename, contentType, base64) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
var blob = new Blob([bytes], { type: contentType });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
};
+99 -71
View File
@@ -2,6 +2,101 @@
// Handles both HTML tables (from Google Sheets) and tab-separated values
window.markdownTablePaste = {
findEditor: function(editorId) {
let textarea = null;
let codeMirror = null;
if (editorId) {
const editorElement = document.getElementById(editorId);
if (editorElement) {
textarea = editorElement.querySelector('textarea');
const cmEl = editorElement.querySelector('.CodeMirror');
if (cmEl && cmEl.CodeMirror) {
codeMirror = cmEl.CodeMirror;
}
}
} else {
const containers = document.querySelectorAll('.EasyMDEContainer');
if (containers.length > 0) {
const lastContainer = containers[containers.length - 1];
textarea = lastContainer.querySelector('textarea');
const cmEl = lastContainer.querySelector('.CodeMirror');
if (cmEl && cmEl.CodeMirror) {
codeMirror = cmEl.CodeMirror;
}
}
}
if (!codeMirror && textarea && window.EasyMDE) {
const easyMDEInstances = window.EasyMDE.instances || [];
for (let i = 0; i < easyMDEInstances.length; i++) {
const instance = easyMDEInstances[i];
if (instance && instance.codemirror) {
const cmTextarea = instance.codemirror.getTextArea();
if (cmTextarea === textarea) {
codeMirror = instance.codemirror;
break;
}
}
}
if (!codeMirror && textarea.parentElement) {
const parent = textarea.parentElement;
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
codeMirror = parent._easyMDEInstance.codemirror;
}
}
}
return { textarea, codeMirror };
},
getValue: function(editorId) {
const found = this.findEditor(editorId);
if (found.codeMirror) {
return found.codeMirror.getValue();
}
if (found.textarea) {
return found.textarea.value;
}
return null;
},
setValue: function(editorId, text) {
const found = this.findEditor(editorId);
if (found.codeMirror) {
found.codeMirror.setValue(text ?? '');
return true;
}
if (found.textarea) {
found.textarea.value = text ?? '';
found.textarea.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
return false;
},
insertAtCursor: function(editorId, text) {
const found = this.findEditor(editorId);
if (found.codeMirror) {
found.codeMirror.replaceSelection(text);
found.codeMirror.focus();
return true;
}
if (found.textarea) {
const start = found.textarea.selectionStart || 0;
const end = found.textarea.selectionEnd || 0;
const before = found.textarea.value.substring(0, start);
const after = found.textarea.value.substring(end);
found.textarea.value = before + text + after;
found.textarea.selectionStart = found.textarea.selectionEnd = before.length + text.length;
found.textarea.dispatchEvent(new Event('input', { bubbles: true }));
found.textarea.focus();
return true;
}
return false;
},
/**
* Initializes paste handler for a MarkdownEditor instance.
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
@@ -13,78 +108,11 @@ window.markdownTablePaste = {
const tryInitialize = function() {
attempts++;
let textarea = null;
let codeMirror = null;
// Try to find the textarea element
if (editorId) {
const editorElement = document.getElementById(editorId);
if (editorElement) {
textarea = editorElement.querySelector('textarea');
}
} else {
// Find the most recently created EasyMDE textarea (last one in DOM)
const containers = document.querySelectorAll('.EasyMDEContainer');
if (containers.length > 0) {
const lastContainer = containers[containers.length - 1];
textarea = lastContainer.querySelector('textarea');
}
// Fallback: try to find any textarea near an editor-toolbar
if (!textarea) {
const toolbars = document.querySelectorAll('.editor-toolbar');
if (toolbars.length > 0) {
const lastToolbar = toolbars[toolbars.length - 1];
const nextSibling = lastToolbar.nextElementSibling;
if (nextSibling && nextSibling.tagName === 'TEXTAREA') {
textarea = nextSibling;
}
}
}
// Another fallback: find any textarea that's a child of a container with editor classes
if (!textarea) {
const allTextareas = document.querySelectorAll('textarea');
for (let ta of allTextareas) {
const parent = ta.parentElement;
if (parent && (
parent.classList.contains('EasyMDEContainer') ||
parent.classList.contains('editor') ||
parent.querySelector('.editor-toolbar')
)) {
textarea = ta;
break;
}
}
}
}
// If we found a textarea, try to get the CodeMirror instance from EasyMDE
if (textarea) {
if (window.EasyMDE) {
const easyMDEInstances = window.EasyMDE.instances || [];
for (let i = 0; i < easyMDEInstances.length; i++) {
const instance = easyMDEInstances[i];
if (instance && instance.codemirror) {
const cmTextarea = instance.codemirror.getTextArea();
if (cmTextarea === textarea) {
codeMirror = instance.codemirror;
break;
}
}
}
// Alternative: try to get CodeMirror from the textarea's parent
if (!codeMirror && textarea.parentElement) {
const parent = textarea.parentElement;
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
codeMirror = parent._easyMDEInstance.codemirror;
}
}
}
const found = window.markdownTablePaste.findEditor(editorId);
const textarea = found.textarea;
const codeMirror = found.codeMirror;
if (textarea || codeMirror) {
const target = codeMirror || textarea;
if (target) {
+23
View File
@@ -0,0 +1,23 @@
# Import Event Catalog
**Created:** 2026-08-29
**Last updated:** 2026-08-29
**Description:** How `/events/import` adds event definitions from CSV.
## Where this is
This is the **event catalog** (names, team size, format). It is not student event rankings (`/students/event-ranking/import`) and not the calendar schedule (`/calendar/event-occurrences/import`).
## Steps
1. Sign in as an Administrator.
2. Open **Events** and click **Import**, or go to `/events/import`.
3. Upload a CSV, **Parse**, review new vs existing counts, then **Save to Database**.
Existing event names are skipped. Re-importing the same catalog is safe.
## Required columns
`Event`, `Team Size`, `State Count`
Optional columns the parser already reads include `Short Name`, `EventFormat`, `Level of Effort`, `Eligibility`, `Description`, `Theme`, `Documentation`, `State Presubmission`, `Semifinalist Activity`, and `Regional Notes`.
+96
View File
@@ -0,0 +1,96 @@
# Page printer
**Created:** 2026-08-29
**Last updated:** 2026-09-03
**Description:** Merge a markdown template onto students, teams, or events and print one page per match. Save the recipe as a print preset.
## Where to open it
Sign in and go to **Tools → Page printer** (`/print`).
## Write a template
Edit the markdown on the printer page. Use `{{tokens}}` for values. **Insert token** opens a searchable list; click a token to insert it at the caret, or use the copy icon to put `{{Name}}` on the clipboard.
```markdown
# Interview — {{FirstName}} {{LastName}}
| Grade | Interview Time | Application |
| --- | --- | --- |
| {{Grade}} | {{Interview Time}} | {{Application}} |
1. Why did you join TSA?
2. What events interest you?
**Event preferences**
| 1st | 2nd | 3rd | 4th | 5th | 6th |
| --- | --- | --- | --- | --- | --- |
| {{Rank1}} | {{Rank2}} | {{Rank3}} | {{Rank4}} | {{Rank5}} | {{Rank6}} |
| {{Rank1.Attributes}} | {{Rank2.Attributes}} | {{Rank3.Attributes}} | {{Rank4.Attributes}} | {{Rank5.Attributes}} | {{Rank6.Attributes}} |
{{Legend}}
```
Or use `{{RankedEvents}}` for the ranking-index badge row instead of the table.
Unknown tokens stay visible so typos are obvious. Empty values (including a missing Interview Time or an unset rank) print blank.
Student pages also have event-rank tokens from **Student Event Ranks**. See [event-ranking-import.md](event-ranking-import.md).
- `{{RankedEvents}}` prints that student’s preferences as ranking-index badges (colored rank dot, short name, attribute marks).
- `{{Rank1}}` through `{{Rank10}}` print the official event name at that preference.
- `{{Rank1.ShortName}}` / `{{Rank1.Attributes}}` (through 10) print the catalog short name and the same attribute marks shown on **Student Event Ranks** (level of effort, individual, on-site, regional, presubmission).
Event pages can put the matching student list under the event name:
```markdown
# {{Name}}
{{EventAttributes}}
{{RankedStudents}}
```
`{{RankedStudents}}` prints everyone who ranked that event as badges (colored rank dot and first name), same sort as **Events by Student** on the ranking index: rank, then grade + TSA year. `{{EventAttributes}}` is also available on team pages.
On event and team pages, `{{RegionalTeamCount}}` and `{{StateTeamCount}}` print how many teams the chapter may send at regionals and state. `{{NationalEligibility}}` is the nationals eligibility text (not a team count). `{{Eligibility}}` still works as the same value.
Put `{{Legend}}` where you want the attribute-mark key (same marks as the Teams printout legend).
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. **Answer lines** on the printer page sets the height of each token; add another `{{AnswerSpace}}` to stack a second block.
Additional-field tokens use the same names as the Students index columns (for example `{{Interview Time}}`). Those values come from each student's `#Student:{id}` `## Additional fields` table. See [student-notes-import.md](student-notes-import.md).
Templates used to live on **Notes**. After this change they are stored on the print preset. Old template notes can be deleted from Notes once `/print` looks right.
## Filters
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, markdown, entity type, and filters. It does not store merged pages. Each Preview uses the current roster and notes.
1. Write the template, set the entity and filters.
2. Click **Save**. If no preset is selected, enter a name in the dialog.
3. Later, choose a preset, click **Preview**, then **Print**.
4. **Save** overwrites the selected preset. **Delete** removes the preset, not the editor text. **New** starts a blank template.
Switching presets, clicking **New**, or leaving the page asks before discarding unsaved template or filter changes. To keep a copy under a new name, clear the preset (or click **New** after copying the markdown), then **Save**.
## Print
**Preview** builds the pages and opens a lightbox. **Print** (in the lightbox or on the page, while the preview is still current) opens the browser print dialog. Navigation and the editor are hidden.
**New page per record** (on by default, saved with the preset) starts each match on a new sheet. Turn it off to flow records together; `{{PageBreak}}` in the template still works. Content that is taller than one sheet continues naturally.
If that option is off and the template has **exactly one** markdown table, the header prints once and each record appends only the table body row(s). That is the roster layout (name, grade, ranks on one row). Put student/team/event tokens in those body rows. Put `{{Legend}}` above or below the table. Two tables (for example an interview sheet plus a rank table) still print as separate stacked documents. `{{RankedEvents}}`, `{{RankedStudents}}`, and `{{PageBreak}}` inside a stitched table are not supported; use them in the lines around the table, or leave New page per record on.
**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 in the preview lightbox.
+50
View File
@@ -0,0 +1,50 @@
# Import Students and Note Fields
**Created:** 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.
## Where notes live
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.
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
```
Student Name,Grade,TSA year,State ID,Regional ID,National ID,Interview Time,Application,Club Permission Slip,Teacher Rec 1,Teacher Rec 2,Teacher Rec 3
"Last, First",6,1st,,,,3:20-3:35,x,x,Fuqua,Young,
```
- Roster (required): `Student Name` (`Last, First` or `First Last`), `Grade`, `TSA year`.
- Roster (optional): `State ID`, `Regional ID`, `National ID`.
- Every other column becomes a Field/Value row. Later files can add columns without a code change.
- A cell that is only `x` / `X` is stored as `Yes`. Blank stays blank.
- Rank columns `1`–`10`, `Officer`, and `TOTAL # OF EVENTS` are ignored (use `/students/event-ranking/import` for rankings).
A notes-only file without `Grade` will not import. Put roster and leftover columns in the same CSV.
## Merge rules
- New students are inserted. Existing first+last name matches are skipped for the roster (add-only).
- After students are saved, leftover columns are fuzzy-matched by name and merged into notes. Existing students still receive note updates.
- Incoming values win when a field already exists, including a blank cell that clears a previous value.
- New fields are appended. Fields not in this CSV stay. Blank cells are stored and re-imported as a no-op.
- Duplicate rows for the same student collapse into one note match; the last row wins for overlapping field names.
- Importing the same file twice adds no students and writes no note history.
## Steps
1. Sign in as an Administrator.
2. Open **Students** and click **Import**, or go to `/students/import`.
The arrow next to **Import** downloads a CSV template (roster columns, Students index columns, and any other additional fields already in notes).
`/import` still opens this same student page.
3. Upload the student CSV.
4. Review new vs existing counts and leftover field names.
5. **Save to Database**.
## Index columns
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.
+7 -5
View File
@@ -1,7 +1,8 @@
# Year Rollover Runbook
**Created:** 2026-08-14
**Last updated:** 2026-08-28
**Last updated:** 2026-08-29
**Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
## Prerequisites
@@ -36,9 +37,10 @@
- Type the target competition year exactly to enable **Apply rollover**.
- Confirm the destructive dialog. The wizard creates `Data/backups/pre-rollover-yyyyMMdd-HHmmss.db` first; that file is the only undo.
9. **Restart the application** so the home page and printouts show the new competition year.
10. **Add new students** via `/students/create` or `/import`.
- `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners.
11. **Import the new state schedule** from the calendar import page.
10. **Add new students** via `/students/create` or `/students/import`.
- Student import is add-only for roster rows and skips existing first+last name matches, so re-importing a full roster is safe for returners.
- Leftover CSV columns (interview time, application, and so on) are merged into each student's notes. Rank columns stay on `/students/event-ranking/import`. See `docs/instructions/student-notes-import.md`.
11. **Import the new state schedule** from the calendar import page. If the chapter event file changed, refresh the catalog at `/events/import` (add-only by event name).
12. On **Meeting Schedule**, click **Reset** once. That page keeps team/student ids in browser localStorage; after a rollover those ids are stale.
13. Collect new event rankings (CSV import at `/students/event-ranking/import`, or the ranking editor) and run team assignment as usual.
@@ -48,7 +50,7 @@
|---------|------|
| Non-returning students | Returning students (promoted) |
| All teams | Event definitions (national catalog) |
| All event rankings | Notes (including meeting notes by title) |
| All event rankings | Notes (including meeting notes by title; notes for removed students are soft-deleted) |
| All meeting history attendance snapshots | Database backup under `Data/backups/` |
| Event occurrences (when checked) | |
+18
View File
@@ -0,0 +1,18 @@
# Page printer plan
**Created:** 2026-08-29
**Last updated:** 2026-09-03
**Description:** Implementation notes for the Tools page printer (markdown 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: markdown editor on the printer page, token-insert dialog, preview lightbox, entity filters, font size, answer-space lines, Preview/Print, one Save (name dialog when no preset is selected)
- Templates stored on `PrintPreset.TemplateMarkdown` (not Notes). Migration copies old note content, then drops `NoteId`.
- Student pages sort by last name, then first name
- Student event-rank tokens: `{{Rank1}}`–`{{Rank10}}`, `.ShortName` / `.Attributes`, and `{{RankedEvents}}` badges
- Event `{{RankedStudents}}` badges (students who ranked the event) and `{{EventAttributes}}`
- `{{Legend}}` from the shared attribute-mark catalog (`EventAttributeMarks`)
- EF migrations `AddPrintPresets` and `PrintPresetTemplateMarkdown` (applied on next app start)