Compare commits
5
Commits
4c91db37c2
...
c03ffc0833
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03ffc0833 | ||
|
|
1337d9833d | ||
|
|
3712dba974 | ||
|
|
3f50d6e635 | ||
|
|
4cfd85b902 |
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ namespace Core.Parsers;
|
|||||||
|
|
||||||
public class StudentEventRankingParser : CsvParserBase
|
public class StudentEventRankingParser : CsvParserBase
|
||||||
{
|
{
|
||||||
public const int StudentMatchThreshold = 90;
|
|
||||||
public const int EventMatchThreshold = 70;
|
public const int EventMatchThreshold = 70;
|
||||||
public const int EventAmbiguityGap = 8;
|
public const int EventAmbiguityGap = 8;
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ public class StudentEventRankingParser : CsvParserBase
|
|||||||
if (string.IsNullOrEmpty(name))
|
if (string.IsNullOrEmpty(name))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var studentMatch = FindStudent(students, name);
|
var studentMatch = FuzzyStudentMatcher.Find(students, name);
|
||||||
if (studentMatch is null)
|
if (studentMatch is null)
|
||||||
{
|
{
|
||||||
result.Issues.Add(new StudentEventRankingIssue
|
result.Issues.Add(new StudentEventRankingIssue
|
||||||
@@ -172,23 +171,6 @@ public class StudentEventRankingParser : CsvParserBase
|
|||||||
return result;
|
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)
|
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
|
||||||
{
|
{
|
||||||
var scored = events
|
var scored = events
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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\"> </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Core.Printing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity a print preset merges a note onto.
|
||||||
|
/// </summary>
|
||||||
|
public enum PrintEntityType
|
||||||
|
{
|
||||||
|
Student,
|
||||||
|
Team,
|
||||||
|
Event
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Printing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter payload stored on a <see cref="PrintPreset"/>. Unused fields stay null.
|
||||||
|
/// </summary>
|
||||||
|
public class PrintPresetFilters
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
public int? Grade { get; set; }
|
||||||
|
|
||||||
|
public int? TsaYear { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> officers only, <c>false</c> non-officers only, <c>null</c> any.
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsOfficer { get; set; }
|
||||||
|
|
||||||
|
public string? TeamIdentifierContains { get; set; }
|
||||||
|
|
||||||
|
public string? EventNameContains { get; set; }
|
||||||
|
|
||||||
|
public EventFormat? EventFormat { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> regional only, <c>false</c> non-regional only, <c>null</c> any.
|
||||||
|
/// </summary>
|
||||||
|
public bool? RegionalOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true (default), each merged record starts a new printed page.
|
||||||
|
/// <c>{{PageBreak}}</c> in the template still works either way.
|
||||||
|
/// </summary>
|
||||||
|
public bool NewPagePerRecord { get; set; } = true;
|
||||||
|
|
||||||
|
public const int DefaultFontSizePt = 12;
|
||||||
|
public const int MinFontSizePt = 9;
|
||||||
|
public const int MaxFontSizePt = 18;
|
||||||
|
public const int DefaultAnswerSpaceLines = 3;
|
||||||
|
public const int MinAnswerSpaceLines = 1;
|
||||||
|
public const int MaxAnswerSpaceLines = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body font size in points for merged pages.
|
||||||
|
/// </summary>
|
||||||
|
public int FontSizePt { get; set; } = DefaultFontSizePt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruled write-in lines for each <c>{{AnswerSpace}}</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int AnswerSpaceLines { get; set; } = DefaultAnswerSpaceLines;
|
||||||
|
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||||
|
|
||||||
|
public static PrintPresetFilters FromJson(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return new PrintPresetFilters();
|
||||||
|
|
||||||
|
var filters = JsonSerializer.Deserialize<PrintPresetFilters>(json, JsonOptions)
|
||||||
|
?? new PrintPresetFilters();
|
||||||
|
filters.ClampPrintOptions();
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClampPrintOptions()
|
||||||
|
{
|
||||||
|
FontSizePt = Math.Clamp(FontSizePt, MinFontSizePt, MaxFontSizePt);
|
||||||
|
AnswerSpaceLines = Math.Clamp(AnswerSpaceLines, MinAnswerSpaceLines, MaxAnswerSpaceLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToTriState(bool? value) => value switch
|
||||||
|
{
|
||||||
|
true => "yes",
|
||||||
|
false => "no",
|
||||||
|
_ => "any"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool? FromTriState(string? value) => value switch
|
||||||
|
{
|
||||||
|
"yes" => true,
|
||||||
|
"no" => false,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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(" · ", 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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<", "<", StringComparison.Ordinal)
|
||||||
|
.Replace(">", ">", 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("&", "&", StringComparison.Ordinal)
|
||||||
|
.Replace("<", "<", StringComparison.Ordinal)
|
||||||
|
.Replace(">", ">", StringComparison.Ordinal)
|
||||||
|
.Replace("\"", """, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FlattenLines(string value) =>
|
||||||
|
string.Join(' ',
|
||||||
|
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,19 @@ public interface INoteNamingService
|
|||||||
/// <param name="noteTitle">The note title to check</param>
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
||||||
bool IsMeetingNote(string noteTitle);
|
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);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public class NoteNamingService : INoteNamingService
|
|||||||
{
|
{
|
||||||
private const string PageNotePrefix = "#";
|
private const string PageNotePrefix = "#";
|
||||||
private const string MeetingNotePrefix = "#Meeting Notes";
|
private const string MeetingNotePrefix = "#Meeting Notes";
|
||||||
|
private const string StudentNotePrefix = "#Student:";
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public string GetMeetingNoteTitle(DateTime meetingDate)
|
public string GetMeetingNoteTitle(DateTime meetingDate)
|
||||||
@@ -47,4 +48,26 @@ public class NoteNamingService : INoteNamingService
|
|||||||
|
|
||||||
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ namespace Data
|
|||||||
public DbSet<Note> Notes { get; set; }
|
public DbSet<Note> Notes { get; set; }
|
||||||
public DbSet<NoteHistory> NoteHistories { get; set; }
|
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||||
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
||||||
|
public DbSet<PrintPreset> PrintPresets { get; set; }
|
||||||
|
|
||||||
public AppDbContext()
|
public AppDbContext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,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");
|
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 =>
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <b>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("·"));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<"));
|
||||||
|
Assert.That(escaped, Does.Contain(">"));
|
||||||
|
Assert.That(escaped, Does.Not.Contain("<script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Merge_EscapedAsteriskDoesNotStayRaw()
|
||||||
|
{
|
||||||
|
var map = PrintTokenMap.Build(
|
||||||
|
null,
|
||||||
|
new Dictionary<string, string?> { ["FirstName"] = "A*ria" },
|
||||||
|
null);
|
||||||
|
|
||||||
|
var merged = NoteTemplateMerger.Merge("Hi {{FirstName}}", map);
|
||||||
|
|
||||||
|
Assert.That(merged, Is.EqualTo("Hi A\\*ria"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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| --- | --- |"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
<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="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||||
<script src="js/markdownTablePaste.js"></script>
|
<script src="js/markdownTablePaste.js"></script>
|
||||||
|
<script src="js/downloadFile.js"></script>
|
||||||
<script src="js/login.js"></script>
|
<script src="js/login.js"></script>
|
||||||
</body>
|
</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 Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Authentication
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@@ -13,6 +14,11 @@
|
|||||||
<MudTooltip Text="Create New">
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
</MudTooltip>
|
</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">
|
<MudTooltip Text="Printable Descriptions">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
|
|||||||
@@ -321,7 +321,7 @@
|
|||||||
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
if (!result.Canceled)
|
if (result is { Canceled: false })
|
||||||
{
|
{
|
||||||
// Refresh data if meeting was updated or deleted
|
// Refresh data if meeting was updated or deleted
|
||||||
await RefreshMeetingHistories();
|
await RefreshMeetingHistories();
|
||||||
|
|||||||
@@ -664,7 +664,7 @@
|
|||||||
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
|
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JSException ex)
|
catch (JSException)
|
||||||
{
|
{
|
||||||
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
|
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,7 +401,7 @@
|
|||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
// Refresh meeting history if dialog was saved
|
// Refresh meeting history if dialog was saved
|
||||||
if (!result.Canceled && !_isDisposed)
|
if (result is { Canceled: false } && !_isDisposed)
|
||||||
{
|
{
|
||||||
await LoadMeetingHistory();
|
await LoadMeetingHistory();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,11 @@
|
|||||||
|
|
||||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
||||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Class="@(removed ? "" : "d-none")"
|
Class="@(removed ? "" : "d-none")"
|
||||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
||||||
Style="cursor: pointer;">
|
aria-label="Restore team" />
|
||||||
</MudIcon>
|
|
||||||
@{
|
@{
|
||||||
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||||
team,
|
team,
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||||
|
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private Student? student;
|
private Student? student;
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="5">
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
|
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@
|
|||||||
private FormChangeTracker? _formChangeTracker;
|
private FormChangeTracker? _formChangeTracker;
|
||||||
private EditContext? _editContext;
|
private EditContext? _editContext;
|
||||||
private List<string> _validationErrors = new();
|
private List<string> _validationErrors = new();
|
||||||
|
private StudentNotePanel? _notePanel;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -120,6 +126,8 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync();
|
||||||
|
if (_notePanel is not null)
|
||||||
|
await _notePanel.SaveAsync();
|
||||||
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
|
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
|
||||||
_formChangeTracker?.AllowNavigation();
|
_formChangeTracker?.AllowNavigation();
|
||||||
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
|
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
|
||||||
|
|||||||
@@ -2,17 +2,40 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using WebApp.Authentication
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@using Core.Notes
|
||||||
|
@using Core.Parsers
|
||||||
|
@using WebApp.Services
|
||||||
|
|
||||||
<PageHeader Title="Students">
|
<PageHeader Title="Students">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudTooltip Text="Create New">
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
</MudTooltip>
|
</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">
|
<MudTooltip Text="Event Rankings">
|
||||||
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@@ -24,6 +47,7 @@
|
|||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudDataGrid T="Student"
|
<MudDataGrid T="Student"
|
||||||
|
@key="NoteFieldColumnsKey"
|
||||||
ServerData="ServerReload"
|
ServerData="ServerReload"
|
||||||
@ref="_dataGrid"
|
@ref="_dataGrid"
|
||||||
Filterable="true"
|
Filterable="true"
|
||||||
@@ -65,6 +89,15 @@
|
|||||||
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
|
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
|
||||||
</CellTemplate>
|
</CellTemplate>
|
||||||
</PropertyColumn>
|
</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>
|
</Columns>
|
||||||
<PagerContent>
|
<PagerContent>
|
||||||
<MudDataGridPager T="Student"></MudDataGridPager>
|
<MudDataGridPager T="Student"></MudDataGridPager>
|
||||||
@@ -77,12 +110,20 @@
|
|||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
private CancellationTokenSource? _cancellationTokenSource;
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
private bool _isDisposed = false;
|
private bool _isDisposed = false;
|
||||||
|
private List<string> _noteFieldColumns = [];
|
||||||
|
private Dictionary<int, string?> _noteContentByStudentId = [];
|
||||||
|
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_cancellationTokenSource = new CancellationTokenSource();
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
|
||||||
{
|
{
|
||||||
if (_isDisposed)
|
if (_isDisposed)
|
||||||
@@ -104,6 +145,9 @@
|
|||||||
var totalItems = await query.CountAsync(cancellationToken);
|
var totalItems = await query.CountAsync(cancellationToken);
|
||||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(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>
|
return new GridData<Student>
|
||||||
{
|
{
|
||||||
TotalItems = totalItems,
|
TotalItems = totalItems,
|
||||||
@@ -160,6 +204,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
|
||||||
Context.Students.Remove(studentToDelete);
|
Context.Students.Remove(studentToDelete);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -203,4 +248,42 @@
|
|||||||
}
|
}
|
||||||
await ValueTask.CompletedTask;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/settings/chapter"
|
@page "/settings/chapter"
|
||||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@using WebApp.Authentication
|
@using WebApp.Authentication
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@@ -7,12 +8,13 @@
|
|||||||
@using Core.Models
|
@using Core.Models
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject IChapterSettingsWriter ChapterSettingsWriter
|
@inject IChapterSettingsWriter ChapterSettingsWriter
|
||||||
|
@inject INotesService NotesService
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="Chapter Settings"
|
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">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||||
|
|
||||||
@@ -90,6 +92,41 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</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">
|
<MudPaper Class="pa-6">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -125,14 +162,52 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private Models.ChapterSettings? _settings;
|
private Models.ChapterSettings? _settings;
|
||||||
|
private string _noteFieldsText = string.Empty;
|
||||||
|
private IReadOnlyList<string> _availableFields = [];
|
||||||
private bool _isSaving;
|
private bool _isSaving;
|
||||||
private string? _statusMessage;
|
private string? _statusMessage;
|
||||||
private Severity _statusSeverity = Severity.Success;
|
private Severity _statusSeverity = Severity.Success;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
?? new Models.ChapterSettings();
|
_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()
|
private async Task SaveSettings()
|
||||||
@@ -144,18 +219,41 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ChapterSettingsWriter.WriteAsync(_settings);
|
_settings.StudentIndexNoteFields = [.. SelectedFields()];
|
||||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
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;
|
_statusSeverity = Severity.Success;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
return;
|
||||||
_statusMessage = $"Error saving settings: {ex.Message}";
|
_statusMessage = $"Error saving settings: {ex.Message}";
|
||||||
_statusSeverity = Severity.Error;
|
_statusSeverity = Severity.Error;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
_isSaving = false;
|
_isSaving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
@using WebApp.Models
|
@using Core.Printing
|
||||||
|
@using WebApp.Models
|
||||||
<MudPaper>
|
<MudPaper>
|
||||||
<h3>Legend</h3>
|
<h3>Legend</h3>
|
||||||
|
|
||||||
<MudContainer>
|
<MudContainer>
|
||||||
<ul>
|
<ul>
|
||||||
<li>@AppIcons.LevelOfEffortIcon(1) - Level of Effort </li>
|
@foreach (var mark in EventAttributeMarks.LegendItems)
|
||||||
<li>@AppIcons.IndividualEvent - Individual Event </li>
|
{
|
||||||
<li>@AppIcons.RegionalEvent - Regional </li>
|
<li>@mark.Symbol - @mark.Label</li>
|
||||||
<li>@AppIcons.OnSiteActivity - On-site Activity</li>
|
}
|
||||||
<li>@AppIcons.PresubmissionEvent - Pre-submission</li>
|
|
||||||
<li>@AppIcons.PresentationEvent - Interview Or Presentation</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
{
|
{
|
||||||
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
|
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Error handling - could show snackbar if we had access
|
// Error handling - could show snackbar if we had access
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
var isExpanded = GetNoteExpanded(noteId);
|
var isExpanded = GetNoteExpanded(noteId);
|
||||||
<MudExpansionPanel @key="@($"note-{noteId}")"
|
<MudExpansionPanel @key="@($"note-{noteId}")"
|
||||||
Icon="@Icons.Material.Filled.Note"
|
Icon="@Icons.Material.Filled.Note"
|
||||||
IsExpanded="@isExpanded"
|
Expanded="@isExpanded"
|
||||||
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
|
ExpandedChanged="@((bool expanded) => OnPanelExpandedChanged(noteId, expanded))"
|
||||||
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
|
Class="@MarkdownHelper.GetNoteColorClass(noteId)">
|
||||||
<TitleContent>
|
<TitleContent>
|
||||||
@@ -71,15 +71,16 @@
|
|||||||
</MudText>
|
</MudText>
|
||||||
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
|
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
|
||||||
{
|
{
|
||||||
|
<div @onclick:stopPropagation="true" class="flex-shrink-0">
|
||||||
|
<MudTooltip Text="@(note.IsPinned ? "Unpin note" : "Pin note")">
|
||||||
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
|
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
|
||||||
OnClick="() => TogglePin(note)"
|
OnClick="() => TogglePin(note)"
|
||||||
OnClick:StopPropagation="true"
|
|
||||||
Variant="Variant.Text"
|
Variant="Variant.Text"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
|
Color="@(note.IsPinned ? Color.Primary : Color.Default)"
|
||||||
Disabled="@(IsPinDisabled(note))"
|
Disabled="@(IsPinDisabled(note))" />
|
||||||
Title="@(note.IsPinned ? "Unpin note" : "Pin note")"
|
</MudTooltip>
|
||||||
Class="flex-shrink-0" />
|
</div>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</TitleContent>
|
</TitleContent>
|
||||||
@@ -301,7 +302,7 @@
|
|||||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
|
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, GetDefaultDialogOptions());
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
if (!result.Canceled && !_isDisposed)
|
if (result is { Canceled: false } && !_isDisposed)
|
||||||
{
|
{
|
||||||
await LoadNotes();
|
await LoadNotes();
|
||||||
}
|
}
|
||||||
@@ -327,7 +328,7 @@
|
|||||||
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
|
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
|
||||||
var result = await dialog.Result;
|
var result = await dialog.Result;
|
||||||
|
|
||||||
if (!result.Canceled && !_isDisposed)
|
if (result is { Canceled: false } && !_isDisposed)
|
||||||
{
|
{
|
||||||
await LoadNotes();
|
await LoadNotes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,26 @@
|
|||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div @ref="_anchorElement"
|
<div @onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
|
||||||
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
|
|
||||||
@onmouseleave="@(() => _popoverOpen = false)"
|
@onmouseleave="@(() => _popoverOpen = false)"
|
||||||
style="display: inline-block;">
|
style="display: inline-block; position: relative;">
|
||||||
|
<MudTooltip Text="@TooltipText">
|
||||||
<MudButton StartIcon="@IconValue"
|
<MudButton StartIcon="@IconValue"
|
||||||
OnClick="OpenDialog"
|
OnClick="OpenDialog"
|
||||||
Variant="@Variant"
|
Variant="@Variant"
|
||||||
Size="@Size"
|
Size="@Size"
|
||||||
Color="@ButtonColor"
|
Color="@ButtonColor"
|
||||||
Tooltip="@TooltipText"
|
|
||||||
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
|
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
|
||||||
@if (!string.IsNullOrEmpty(ButtonText))
|
@if (!string.IsNullOrEmpty(ButtonText))
|
||||||
{
|
{
|
||||||
@ButtonText
|
@ButtonText
|
||||||
}
|
}
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</div>
|
</MudTooltip>
|
||||||
<MudPopover @bind-Open="_popoverOpen"
|
<MudPopover Open="_popoverOpen"
|
||||||
AnchorOrigin="Origin.BottomCenter"
|
AnchorOrigin="Origin.BottomCenter"
|
||||||
TransformOrigin="Origin.TopCenter"
|
TransformOrigin="Origin.TopCenter"
|
||||||
Elevation="8"
|
Elevation="8">
|
||||||
Anchor="@_anchorElement">
|
|
||||||
<ChildContent>
|
<ChildContent>
|
||||||
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
|
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
|
||||||
{
|
{
|
||||||
@@ -42,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</MudPopover>
|
</MudPopover>
|
||||||
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
@@ -68,7 +67,6 @@
|
|||||||
private int _noteId = 0;
|
private int _noteId = 0;
|
||||||
private string _noteContent = string.Empty;
|
private string _noteContent = string.Empty;
|
||||||
private bool _popoverOpen = false;
|
private bool _popoverOpen = false;
|
||||||
private ElementReference _anchorElement;
|
|
||||||
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
|
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
|
||||||
private CancellationTokenSource? _cancellationTokenSource;
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
private bool _isDisposed = false;
|
private bool _isDisposed = false;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
|
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
|
||||||
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
|
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
|
||||||
|
<MudNavLink Href="/print" Icon="@Icons.Material.Filled.Print">Page printer</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
|
|
||||||
<AuthorizeView Roles="Administrator">
|
<AuthorizeView Roles="Administrator">
|
||||||
|
|||||||
+24
-91
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Printing;
|
||||||
using MudBlazor;
|
using MudBlazor;
|
||||||
|
|
||||||
namespace WebApp.Models
|
namespace WebApp.Models
|
||||||
@@ -14,50 +15,26 @@ namespace WebApp.Models
|
|||||||
public static string Captain = Icons.Material.Filled.Star;
|
public static string Captain = Icons.Material.Filled.Star;
|
||||||
public static string Registration = Icons.Material.Filled.AppRegistration;
|
public static string Registration = Icons.Material.Filled.AppRegistration;
|
||||||
public static string EventCalendar = Icons.Material.Filled.Event;
|
public static string EventCalendar = Icons.Material.Filled.Event;
|
||||||
public static string LevelOfEffortIcon(int? loe)
|
public static string LevelOfEffortIcon(int? loe) =>
|
||||||
|
loe switch
|
||||||
{
|
{
|
||||||
|
1 => EventAttributeMarks.LevelOfEffort1,
|
||||||
return loe switch
|
2 => EventAttributeMarks.LevelOfEffort2,
|
||||||
{
|
3 => EventAttributeMarks.LevelOfEffort3,
|
||||||
1 => "○",
|
|
||||||
2 => "◐",
|
|
||||||
3 => "⬤",
|
|
||||||
_ => Icons.Material.Filled.QuestionMark
|
_ => Icons.Material.Filled.QuestionMark
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/*https://unicodeplus.com/search*/
|
public static string OnSiteActivity => EventAttributeMarks.OnSite;
|
||||||
public static string OnSiteActivity = "ⓐ";
|
public static string RegionalEvent => EventAttributeMarks.Regional;
|
||||||
public static string RegionalEvent = "ⓡ";
|
public static string IndividualEvent => EventAttributeMarks.Individual;
|
||||||
public static string IndividualEvent = "ⓘ";
|
public static string PresubmissionEvent => EventAttributeMarks.Presubmission;
|
||||||
public static string PresubmissionEvent = "ⓟ";
|
public static string PresentationEvent => "";
|
||||||
public static string PresentationEvent = "";
|
|
||||||
|
|
||||||
// Tooltip mapping for icon unicode characters
|
public static IReadOnlyDictionary<string, string> IconTooltips { get; } =
|
||||||
public static Dictionary<string, string> IconTooltips => new()
|
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal);
|
||||||
{
|
|
||||||
{ 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" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Color mapping for icon unicode characters
|
public static IReadOnlyDictionary<string, string> IconColors { get; } =
|
||||||
public static Dictionary<string, string> IconColors => new()
|
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Color, StringComparer.Ordinal);
|
||||||
{
|
|
||||||
{ 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 string EventEffort(EventDefinition eventDefinition)
|
public static string EventEffort(EventDefinition eventDefinition)
|
||||||
{
|
{
|
||||||
@@ -96,63 +73,19 @@ namespace WebApp.Models
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string RankedEventColor(int rank)
|
public static string RankedEventColor(int rank) => EventRankLegend.ColorHex(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 GetOrdinal(int num)
|
public static string GetOrdinal(int num) => EventRankLegend.Ordinal(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 GetOrdinalSuperscript(int number)
|
public static string GetOrdinalSuperscript(int number)
|
||||||
{
|
{
|
||||||
var suffix = number switch
|
var ordinal = EventRankLegend.Ordinal(number);
|
||||||
{
|
var suffixAt = 0;
|
||||||
11 or 12 or 13 => "th",
|
while (suffixAt < ordinal.Length && (char.IsDigit(ordinal[suffixAt]) || ordinal[suffixAt] == '-'))
|
||||||
_ => (number % 10) switch
|
suffixAt++;
|
||||||
{
|
return suffixAt is 0 || suffixAt == ordinal.Length
|
||||||
1 => "st",
|
? ordinal
|
||||||
2 => "nd",
|
: $"{ordinal[..suffixAt]}<sup>{ordinal[suffixAt..]}</sup>";
|
||||||
3 => "rd",
|
|
||||||
_ => "th"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return $"{number}<sup>{suffix}</sup>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Core.Models;
|
using Core.Models;
|
||||||
|
using Core.Notes;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace WebApp.Models;
|
namespace WebApp.Models;
|
||||||
|
|
||||||
@@ -51,4 +53,26 @@ public class ChapterSettings
|
|||||||
/// School level for the chapter (null = import both MS and HS events)
|
/// School level for the chapter (null = import both MS and HS events)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SchoolLevel? SchoolLevel { get; set; }
|
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
@@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Core.Notes;
|
||||||
using VisNetwork.Blazor;
|
using VisNetwork.Blazor;
|
||||||
using WebApp;
|
using WebApp;
|
||||||
using WebApp.Authentication;
|
using WebApp.Authentication;
|
||||||
@@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath))
|
|||||||
var baseConfig = File.ReadAllText(baseAppSettingsPath);
|
var baseConfig = File.ReadAllText(baseAppSettingsPath);
|
||||||
var baseDoc = JsonDocument.Parse(baseConfig);
|
var baseDoc = JsonDocument.Parse(baseConfig);
|
||||||
|
|
||||||
var templateSettings = new Dictionary<string, object?>();
|
JsonObject templateSettings = [];
|
||||||
|
|
||||||
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
|
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))
|
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
|
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<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
|
||||||
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
|
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
|
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
|
||||||
|
builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.INotePrintService, WebApp.Services.NotePrintService>();
|
||||||
|
builder.Services.AddScoped<WebApp.Services.IPrintPresetService, WebApp.Services.PrintPresetService>();
|
||||||
|
|
||||||
builder.Services.Configure<StateScheduleHandoutOptions>(
|
builder.Services.Configure<StateScheduleHandoutOptions>(
|
||||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using WebApp.Models;
|
using WebApp.Models;
|
||||||
|
|
||||||
namespace WebApp.Services;
|
namespace WebApp.Services;
|
||||||
@@ -8,6 +9,11 @@ namespace WebApp.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChapterSettingsWriter : IChapterSettingsWriter
|
public class ChapterSettingsWriter : IChapterSettingsWriter
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<ChapterSettingsWriter> _logger;
|
private readonly ILogger<ChapterSettingsWriter> _logger;
|
||||||
@@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter
|
|||||||
var appSettingsPath = GetAppSettingsPath();
|
var appSettingsPath = GetAppSettingsPath();
|
||||||
var dataDir = Path.GetDirectoryName(appSettingsPath);
|
var dataDir = Path.GetDirectoryName(appSettingsPath);
|
||||||
if (dataDir != null && !Directory.Exists(dataDir))
|
if (dataDir != null && !Directory.Exists(dataDir))
|
||||||
{
|
|
||||||
Directory.CreateDirectory(dataDir);
|
Directory.CreateDirectory(dataDir);
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, object?> root;
|
JsonObject root;
|
||||||
if (File.Exists(appSettingsPath))
|
if (File.Exists(appSettingsPath))
|
||||||
{
|
{
|
||||||
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
|
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
|
||||||
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
|
root = JsonNode.Parse(existingJson) as JsonObject ?? [];
|
||||||
?? [];
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
root = [];
|
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 };
|
await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken);
|
||||||
var json = JsonSerializer.Serialize(root, options);
|
|
||||||
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
|
if (_configuration is IConfigurationRoot configurationRoot)
|
||||||
|
configurationRoot.Reload();
|
||||||
|
|
||||||
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
|
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
|
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
|
var settings = ChapterSettings.FromConfiguration(_configuration);
|
||||||
?? new ChapterSettings();
|
|
||||||
settings.CompetitionYear = competitionYear;
|
settings.CompetitionYear = competitionYear;
|
||||||
await WriteAsync(settings, cancellationToken);
|
await WriteAsync(settings, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -22,6 +22,26 @@ public interface INotesService
|
|||||||
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
|
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
|
||||||
Task<Note?> GetPageNoteAsync(string pageIdentifier);
|
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>
|
/// <summary>
|
||||||
/// Gets all history entries for a note.
|
/// Gets all history entries for a note.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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");
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Notes;
|
||||||
using Core.Services;
|
using Core.Services;
|
||||||
using Data;
|
using Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -46,8 +47,9 @@ public class NotesService : INotesService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await query
|
return await query
|
||||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
|
||||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
|
||||||
|
.ThenByDescending(n => n.UpdatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,84 @@ public class NotesService : INotesService
|
|||||||
.FirstOrDefaultAsync();
|
.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)
|
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||||
{
|
{
|
||||||
return await _context.NoteHistories
|
return await _context.NoteHistories
|
||||||
@@ -253,9 +333,9 @@ public class NotesService : INotesService
|
|||||||
{
|
{
|
||||||
return await _context.Notes
|
return await _context.Notes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(n => n.IsDeleted)
|
.Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
|
||||||
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
|
||||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
.ThenByDescending(n => n.UpdatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService
|
|||||||
private readonly AppDbContext _context;
|
private readonly AppDbContext _context;
|
||||||
private readonly IDatabaseBackupService _backupService;
|
private readonly IDatabaseBackupService _backupService;
|
||||||
private readonly IChapterSettingsWriter _chapterSettingsWriter;
|
private readonly IChapterSettingsWriter _chapterSettingsWriter;
|
||||||
|
private readonly INotesService _notesService;
|
||||||
private readonly ILogger<YearRolloverService> _logger;
|
private readonly ILogger<YearRolloverService> _logger;
|
||||||
|
|
||||||
public YearRolloverService(
|
public YearRolloverService(
|
||||||
AppDbContext context,
|
AppDbContext context,
|
||||||
IDatabaseBackupService backupService,
|
IDatabaseBackupService backupService,
|
||||||
IChapterSettingsWriter chapterSettingsWriter,
|
IChapterSettingsWriter chapterSettingsWriter,
|
||||||
|
INotesService notesService,
|
||||||
ILogger<YearRolloverService> logger)
|
ILogger<YearRolloverService> logger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_backupService = backupService;
|
_backupService = backupService;
|
||||||
_chapterSettingsWriter = chapterSettingsWriter;
|
_chapterSettingsWriter = chapterSettingsWriter;
|
||||||
|
_notesService = notesService;
|
||||||
_logger = logger;
|
_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.");
|
$"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);
|
_context.Students.RemoveRange(toRemove);
|
||||||
|
|
||||||
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
|
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
|
||||||
|
|||||||
@@ -16,6 +16,16 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mud-overlay,
|
||||||
|
.mud-dialog-container,
|
||||||
|
.mud-overlay-dialog {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nobrk {
|
.nobrk {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
@@ -23,6 +33,53 @@
|
|||||||
.pagebreak {
|
.pagebreak {
|
||||||
page-break-after: always;
|
page-break-after: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content,
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3,
|
||||||
|
.markdown-content p,
|
||||||
|
.markdown-content li,
|
||||||
|
.markdown-content td,
|
||||||
|
.markdown-content th {
|
||||||
|
color: #000;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2 {
|
||||||
|
border-bottom-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th,
|
||||||
|
.markdown-content table td {
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-print-page,
|
||||||
|
.note-print-page .markdown-content {
|
||||||
|
font-size: var(--print-font-size, 12pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-answer-space {
|
||||||
|
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{
|
.ranked-event-column > div:only-child{
|
||||||
@@ -42,6 +99,10 @@
|
|||||||
.event-rank-4 { background-color: #ffe599; }
|
.event-rank-4 { background-color: #ffe599; }
|
||||||
.event-rank-5 { background-color: #fff2cc; }
|
.event-rank-5 { background-color: #fff2cc; }
|
||||||
.event-rank-6 { background-color: #fffaea; }
|
.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 {
|
.pre-wrap-text {
|
||||||
@@ -324,6 +385,89 @@
|
|||||||
height: auto;
|
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 classes - pastel background colors */
|
||||||
.note-color-0 {
|
.note-color-0 {
|
||||||
background-color: #e3f2fd;
|
background-color: #e3f2fd;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,69 +2,33 @@
|
|||||||
// Handles both HTML tables (from Google Sheets) and tab-separated values
|
// Handles both HTML tables (from Google Sheets) and tab-separated values
|
||||||
|
|
||||||
window.markdownTablePaste = {
|
window.markdownTablePaste = {
|
||||||
/**
|
findEditor: function(editorId) {
|
||||||
* Initializes paste handler for a MarkdownEditor instance.
|
|
||||||
* Finds the textarea or CodeMirror instance created by EasyMDE and attaches paste event handler.
|
|
||||||
* @param {string} editorId - The ID of the editor wrapper element, or null to find by class
|
|
||||||
*/
|
|
||||||
initialize: function(editorId) {
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 5;
|
|
||||||
|
|
||||||
const tryInitialize = function() {
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
let textarea = null;
|
let textarea = null;
|
||||||
let codeMirror = null;
|
let codeMirror = null;
|
||||||
|
|
||||||
// Try to find the textarea element
|
|
||||||
if (editorId) {
|
if (editorId) {
|
||||||
const editorElement = document.getElementById(editorId);
|
const editorElement = document.getElementById(editorId);
|
||||||
if (editorElement) {
|
if (editorElement) {
|
||||||
textarea = editorElement.querySelector('textarea');
|
textarea = editorElement.querySelector('textarea');
|
||||||
|
const cmEl = editorElement.querySelector('.CodeMirror');
|
||||||
|
if (cmEl && cmEl.CodeMirror) {
|
||||||
|
codeMirror = cmEl.CodeMirror;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Find the most recently created EasyMDE textarea (last one in DOM)
|
|
||||||
const containers = document.querySelectorAll('.EasyMDEContainer');
|
const containers = document.querySelectorAll('.EasyMDEContainer');
|
||||||
if (containers.length > 0) {
|
if (containers.length > 0) {
|
||||||
const lastContainer = containers[containers.length - 1];
|
const lastContainer = containers[containers.length - 1];
|
||||||
textarea = lastContainer.querySelector('textarea');
|
textarea = lastContainer.querySelector('textarea');
|
||||||
}
|
const cmEl = lastContainer.querySelector('.CodeMirror');
|
||||||
|
if (cmEl && cmEl.CodeMirror) {
|
||||||
// Fallback: try to find any textarea near an editor-toolbar
|
codeMirror = cmEl.CodeMirror;
|
||||||
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 (!codeMirror && textarea && window.EasyMDE) {
|
||||||
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 || [];
|
const easyMDEInstances = window.EasyMDE.instances || [];
|
||||||
|
|
||||||
for (let i = 0; i < easyMDEInstances.length; i++) {
|
for (let i = 0; i < easyMDEInstances.length; i++) {
|
||||||
const instance = easyMDEInstances[i];
|
const instance = easyMDEInstances[i];
|
||||||
if (instance && instance.codemirror) {
|
if (instance && instance.codemirror) {
|
||||||
@@ -76,7 +40,6 @@ window.markdownTablePaste = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alternative: try to get CodeMirror from the textarea's parent
|
|
||||||
if (!codeMirror && textarea.parentElement) {
|
if (!codeMirror && textarea.parentElement) {
|
||||||
const parent = textarea.parentElement;
|
const parent = textarea.parentElement;
|
||||||
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
|
if (parent._easyMDEInstance && parent._easyMDEInstance.codemirror) {
|
||||||
@@ -85,6 +48,71 @@ window.markdownTablePaste = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
* @param {string} editorId - The ID of the editor wrapper element, or null to find by class
|
||||||
|
*/
|
||||||
|
initialize: function(editorId) {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 5;
|
||||||
|
|
||||||
|
const tryInitialize = function() {
|
||||||
|
attempts++;
|
||||||
|
const found = window.markdownTablePaste.findEditor(editorId);
|
||||||
|
const textarea = found.textarea;
|
||||||
|
const codeMirror = found.codeMirror;
|
||||||
|
|
||||||
|
if (textarea || codeMirror) {
|
||||||
const target = codeMirror || textarea;
|
const target = codeMirror || textarea;
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Year Rollover Runbook
|
# Year Rollover Runbook
|
||||||
|
|
||||||
**Created:** 2026-08-14
|
**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.
|
**Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -36,9 +37,10 @@
|
|||||||
- Type the target competition year exactly to enable **Apply rollover**.
|
- 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.
|
- 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.
|
9. **Restart the application** so the home page and printouts show the new competition year.
|
||||||
10. **Add new students** via `/students/create` or `/import`.
|
10. **Add new students** via `/students/create` or `/students/import`.
|
||||||
- `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners.
|
- 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.
|
||||||
11. **Import the new state schedule** from the calendar import page.
|
- 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.
|
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.
|
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) |
|
| Non-returning students | Returning students (promoted) |
|
||||||
| All teams | Event definitions (national catalog) |
|
| 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/` |
|
| All meeting history attendance snapshots | Database backup under `Data/backups/` |
|
||||||
| Event occurrences (when checked) | |
|
| Event occurrences (when checked) | |
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user