feat: import leftover student fields into notes and show them on the roster

Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
+1
View File
@@ -25,6 +25,7 @@
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script>
<script src="js/downloadFile.js"></script>
<script src="js/login.js"></script>
</body>
@@ -0,0 +1,253 @@
@page "/events/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<EventCatalogImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Catalog"
Description="Add new event definitions from CSV. Existing event names are skipped."
Icon="@AppIcons.Events"
ShowBackButton="true"
BackButtonUrl="/events" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Event</code>, <code>Team Size</code>, <code>State Count</code>.
Optional: <code>Short Name</code>, <code>EventFormat</code>, <code>Level of Effort</code>,
<code>Eligibility</code>, <code>Description</code>, <code>Theme</code>,
<code>Documentation</code>, <code>State Presubmission</code>,
<code>Semifinalist Activity</code>, <code>Regional Notes</code>.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_events is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_events.Length event(s) parsed.
@_newEventCount new, @_existingEventCount already in the database.
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _newEventCount == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private EventDefinition[]? _events;
private int _newEventCount;
private int _existingEventCount;
private string? _parseError;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading event catalog CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_events = new EventDefinitionParser(reader).Parse();
var existingNames = await Context.Events
.AsNoTracking()
.Select(e => e.Name)
.ToListAsync(token);
var existingSet = existingNames.ToHashSet();
_existingEventCount = _events.Count(e => existingSet.Contains(e.Name));
_newEventCount = _events.Length - _existingEventCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing event catalog CSV");
_events = null;
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_events is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var evt in _events)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Events.FirstOrDefaultAsync(e => e.Name == evt.Name, token);
if (exists != null)
continue;
await Context.Events.AddAsync(evt, token);
added++;
}
await Context.SaveChangesAsync(token);
if (_isDisposed)
return;
Snackbar.Add($"Added {added} event(s).", Severity.Success);
NavigationManager.NavigateTo("/events");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported events");
if (!_isDisposed)
Snackbar.Add($"Error saving events: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_events = null;
_newEventCount = 0;
_existingEventCount = 0;
_parseError = null;
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,6 +4,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using WebApp.Authentication
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@@ -13,6 +14,11 @@
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Add new catalog events from CSV. Existing names are skipped.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/events/import" Variant="Variant.Outlined">Import</MudButton>
</MudTooltip>
</AuthorizeView>
<MudTooltip Text="Printable Descriptions">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
</MudTooltip>
@@ -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. Imported fields appear in a table and can be edited.</MudText>
<MarkdownEditor Value="@_content"
ValueChanged="@((string? value) => _content = value ?? string.Empty)"
Placeholder="Student notes..."
AutoSaveEnabled="false"
NativeSpellChecker="false" />
}
@code {
[Parameter]
public int StudentId { get; set; }
[Parameter]
public bool ReadOnly { get; set; }
private string _content = string.Empty;
private int? _noteId;
private int _loadedStudentId;
private bool _isLoading = true;
private bool _pasteInitialized;
protected override async Task OnParametersSetAsync()
{
if (StudentId <= 0 || _loadedStudentId == StudentId)
return;
_isLoading = true;
try
{
var note = await NotesService.GetStudentNoteAsync(StudentId);
_noteId = note?.Id;
_content = note?.Content ?? string.Empty;
_loadedStudentId = StudentId;
}
finally
{
_isLoading = false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ReadOnly || _pasteInitialized)
return;
await Task.Delay(150);
await MarkdownTablePasteService.InitializeAsync();
_pasteInitialized = true;
}
public async Task SaveAsync()
{
if (StudentId <= 0)
return;
if (_noteId is null)
{
if (string.IsNullOrWhiteSpace(_content))
return;
var created = await NotesService.CreateNoteAsync(new Note
{
Title = NoteNamingService.GetStudentNoteTitle(StudentId),
Content = _content
});
_noteId = created.Id;
return;
}
var existing = await NotesService.GetNoteAsync(_noteId.Value);
if (existing is null)
return;
if (string.Equals(existing.Content ?? string.Empty, _content, StringComparison.Ordinal))
return;
existing.Content = _content;
await NotesService.UpdateNoteAsync(existing);
}
}
@@ -82,6 +82,10 @@
</MudGrid>
</MudPaper>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
<StudentNotePanel StudentId="student.Id" ReadOnly="true" />
</MudPaper>
@code {
private Student? student;
@@ -52,6 +52,11 @@
</MudSelect>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<StudentNotePanel @ref="_notePanel" StudentId="Student.Id" />
</MudPaper>
</MudItem>
</MudGrid>
</EditForm>
@@ -73,6 +78,7 @@
private FormChangeTracker? _formChangeTracker;
private EditContext? _editContext;
private List<string> _validationErrors = new();
private StudentNotePanel? _notePanel;
protected override async Task OnInitializedAsync()
{
@@ -120,6 +126,8 @@
try
{
await Context.SaveChangesAsync();
if (_notePanel is not null)
await _notePanel.SaveAsync();
Snackbar.Add($"Student '{Student!.FirstNameLastName}' saved successfully.", Severity.Success);
_formChangeTracker?.AllowNavigation();
NavigationManager.NavigateTo(ReturnUrl ?? "/students");
@@ -2,17 +2,40 @@
@attribute [Authorize]
@implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject INotesService NotesService
@inject IConfiguration Configuration
@inject IJSRuntime JSRuntime
@using Core.Notes
@using Core.Parsers
@using WebApp.Services
<PageHeader Title="Students">
<ActionButtons>
<MudTooltip Text="Create New">
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="students/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudButtonGroup Variant="Variant.Outlined">
<MudTooltip Text="Add new students from CSV. Existing names are skipped; leftover columns merge into student notes.">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile" Href="/students/import">Import</MudButton>
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.ArrowDropDown"
AriaLabel="More import actions"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight">
<MudMenuItem Icon="@Icons.Material.Filled.Download"
OnClick="DownloadStudentImportTemplate">
Download CSV template
</MudMenuItem>
</MudMenu>
</MudButtonGroup>
</AuthorizeView>
<MudTooltip Text="Event Rankings">
<MudButton StartIcon="@AppIcons.EventRank" Href="students/event-ranking" Variant="Variant.Outlined">Event Rankings</MudButton>
</MudTooltip>
@@ -24,6 +47,7 @@
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudDataGrid T="Student"
@key="NoteFieldColumnsKey"
ServerData="ServerReload"
@ref="_dataGrid"
Filterable="true"
@@ -65,6 +89,15 @@
<span style="white-space: nowrap;">@((MarkupString)AppIcons.GetOrdinalSuperscript(context.Item.Grade))</span> (@context.Item.TsaYear)
</CellTemplate>
</PropertyColumn>
@foreach (var field in _noteFieldColumns)
{
var fieldName = field;
<TemplateColumn Title="@fieldName" Sortable="false" Filterable="false">
<CellTemplate>
@GetNoteField(context.Item.Id, fieldName)
</CellTemplate>
</TemplateColumn>
}
</Columns>
<PagerContent>
<MudDataGridPager T="Student"></MudDataGridPager>
@@ -77,12 +110,20 @@
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
private List<string> _noteFieldColumns = [];
private Dictionary<int, string?> _noteContentByStudentId = [];
private string NoteFieldColumnsKey => string.Join('\u001f', _noteFieldColumns);
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override void OnParametersSet()
{
_noteFieldColumns = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration);
}
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
{
if (_isDisposed)
@@ -104,6 +145,9 @@
var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
var notes = await NotesService.GetStudentNotesAsync(pagedData.Select(s => s.Id));
_noteContentByStudentId = notes.ToDictionary(k => k.Key, v => v.Value.Content);
return new GridData<Student>
{
TotalItems = totalItems,
@@ -160,6 +204,7 @@
return;
}
await NotesService.SoftDeleteStudentNotesAsync([studentToDelete.Id], cancellationToken);
Context.Students.Remove(studentToDelete);
await Context.SaveChangesAsync(cancellationToken);
@@ -203,4 +248,42 @@
}
await ValueTask.CompletedTask;
}
private async Task DownloadStudentImportTemplate()
{
if (_isDisposed)
return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var fromNotes = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
var leftover = _noteFieldColumns
.Concat(fromNotes)
.Distinct(StringComparer.OrdinalIgnoreCase);
var csv = StudentImportCsvTemplate.Build(leftover);
byte[] bytes = [..System.Text.Encoding.UTF8.GetPreamble(), ..System.Text.Encoding.UTF8.GetBytes(csv)];
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("tsaDownload.fromBase64", "student-import-template.csv", "text/csv;charset=utf-8", base64);
}
catch (JSDisconnectedException)
{
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
if (!_isDisposed)
Snackbar.Add($"Could not download template: {ex.Message}", Severity.Error);
}
}
private string GetNoteField(int studentId, string fieldName)
{
if (!_noteContentByStudentId.TryGetValue(studentId, out var content))
return string.Empty;
return ImportedFieldsTable.GetFieldValue(content, fieldName) ?? string.Empty;
}
}
@@ -0,0 +1,301 @@
@page "/students/import"
@page "/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Parsers
@using Core.Services
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@inject AppDbContext Context
@inject IStudentNotesImportService NotesImportService
@inject IStudentNotesImportSaveService NotesSaveService
@inject INotesService NotesService
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject ILogger<StudentImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Students"
Description="Add new students from CSV. Existing first+last names are skipped; leftover columns merge into student notes."
ShowBackButton="true"
BackButtonUrl="/students" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required columns: <code>Student Name</code>, <code>Grade</code>, <code>TSA year</code>.
Optional IDs are saved on new students. Every other column is merged into that student's notes.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton Variant="Variant.Text" OnClick="HandleClear" Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (!string.IsNullOrEmpty(_parseError))
{
<MudAlert Severity="Severity.Error">@_parseError</MudAlert>
}
else if (_students is null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
<MudAlert Severity="Severity.Success" Dense="true">
@_students.Length student(s) parsed.
@_newStudentCount new, @_existingStudentCount already in the database.
</MudAlert>
@if (_leftoverFieldNames.Count > 0)
{
<MudText Typo="Typo.body2">Leftover note fields: @string.Join(", ", _leftoverFieldNames)</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">No leftover note fields in this file.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@(_isSaving || _students.Length == 0)">
Save to Database
</MudButton>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private Student[]? _students;
private List<string> _leftoverFieldNames = [];
private int _newStudentCount;
private int _existingStudentCount;
private string? _parseError;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
ResetParse();
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading student CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
_parseError = null;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
using var reader = new StreamReader(new MemoryStream(_fileBytes));
_students = new StudentParser(reader).Parse();
_leftoverFieldNames = PeekLeftoverFieldNames(_fileBytes);
var existingNames = await Context.Students
.AsNoTracking()
.Select(s => new { s.FirstName, s.LastName })
.ToListAsync(token);
var existingSet = existingNames
.Select(s => (s.FirstName, s.LastName))
.ToHashSet();
_existingStudentCount = _students.Count(s => existingSet.Contains((s.FirstName, s.LastName)));
_newStudentCount = _students.Length - _existingStudentCount;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing student CSV");
_students = null;
_leftoverFieldNames = [];
_parseError = $"Error parsing CSV: {ex.Message}";
if (!_isDisposed)
Snackbar.Add(_parseError, Severity.Error);
}
finally
{
_isParsing = false;
}
}
private async Task HandleSave()
{
if (_students is null || _fileBytes is null)
{
Snackbar.Add("Parse a CSV first", Severity.Warning);
return;
}
_isSaving = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var added = 0;
foreach (var student in _students)
{
token.ThrowIfCancellationRequested();
var exists = await Context.Students
.FirstOrDefaultAsync(e => e.FirstName == student.FirstName && e.LastName == student.LastName, token);
if (exists != null)
continue;
await Context.Students.AddAsync(student, token);
added++;
}
await Context.SaveChangesAsync(token);
var notesCreated = 0;
var notesUpdated = 0;
if (_leftoverFieldNames.Count > 0)
{
var students = await Context.Students
.AsNoTracking()
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(token);
var notes = await NotesService.GetStudentNotesAsync(students.Select(s => s.Id));
var existing = notes.ToDictionary(k => k.Key, v => v.Value.Content);
await using var csvStream = new MemoryStream(_fileBytes, writable: false);
var parseResult = NotesImportService.Parse(csvStream, students, existing);
if (parseResult.IsSuccess && parseResult.StudentsWithChanges > 0)
{
var saveResult = await NotesSaveService.SaveAsync(parseResult, token);
notesCreated = saveResult.NotesCreated;
notesUpdated = saveResult.NotesUpdated;
}
}
if (_isDisposed)
return;
Snackbar.Add(
$"Added {added} student(s). Notes created {notesCreated}, updated {notesUpdated}.",
Severity.Success);
NavigationManager.NavigateTo("/students");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported students");
if (!_isDisposed)
Snackbar.Add($"Error saving students: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
ResetParse();
}
private void ResetParse()
{
_students = null;
_leftoverFieldNames = [];
_newStudentCount = 0;
_existingStudentCount = 0;
_parseError = null;
}
private static List<string> PeekLeftoverFieldNames(byte[] csvBytes)
{
using var reader = new StreamReader(new MemoryStream(csvBytes));
using var parser = new StudentNotesFieldParser(reader);
return parser.PeekLeftoverFieldNames();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
+104 -6
View File
@@ -1,5 +1,6 @@
@page "/settings/chapter"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
@@ -7,12 +8,13 @@
@using Core.Models
@inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter
@inject INotesService NotesService
@rendermode InteractiveServer
<PageHeader
Title="Chapter Settings"
Description="Configure chapter information. Changes take effect on next application restart." />
Description="Configure chapter information. Student index columns apply the next time you open Students. Printouts that cache chapter name or year may still need a restart." />
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
@@ -90,6 +92,41 @@
</MudGrid>
</MudPaper>
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-4">Student Index Columns</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Field names from each student's imported notes table to show as extra Students index columns. Click a field found in notes to add or remove it, or type names (one per line).
</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 imported fields found in student notes yet.
</MudText>
}
<MudTextField @bind-Value="_noteFieldsText"
Label="Imported field columns"
Variant="Variant.Outlined"
Lines="5"
HelperText="Example: Interview Time" />
</MudPaper>
<MudPaper Class="pa-6">
<MudGrid>
<MudItem xs="12">
@@ -125,14 +162,52 @@
@code {
private Models.ChapterSettings? _settings;
private string _noteFieldsText = string.Empty;
private IReadOnlyList<string> _availableFields = [];
private bool _isSaving;
private string? _statusMessage;
private Severity _statusSeverity = Severity.Success;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings();
_cancellationTokenSource = new CancellationTokenSource();
_settings = Models.ChapterSettings.FromConfiguration(Configuration);
_noteFieldsText = string.Join(Environment.NewLine, _settings.StudentIndexNoteFields);
}
protected override async Task OnInitializedAsync()
{
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
_availableFields = await NotesService.GetImportedFieldNamesAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
}
private IReadOnlyList<string> SelectedFields() =>
_noteFieldsText
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
private bool IsFieldSelected(string field) =>
SelectedFields().Any(selected => selected.Equals(field, StringComparison.OrdinalIgnoreCase));
private void ToggleField(string field)
{
var selected = SelectedFields().ToList();
var index = selected.FindIndex(name => name.Equals(field, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
selected.RemoveAt(index);
else
selected.Add(field);
_noteFieldsText = string.Join(Environment.NewLine, selected);
}
private async Task SaveSettings()
@@ -144,18 +219,41 @@
try
{
await ChapterSettingsWriter.WriteAsync(_settings);
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_settings.StudentIndexNoteFields = [.. SelectedFields()];
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
await ChapterSettingsWriter.WriteAsync(_settings, cancellationToken);
if (_isDisposed)
return;
_statusMessage = "Settings saved. Open Students again to see the updated index columns.";
_statusSeverity = Severity.Success;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
if (_isDisposed)
return;
_statusMessage = $"Error saving settings: {ex.Message}";
_statusSeverity = Severity.Error;
}
finally
{
_isSaving = false;
if (!_isDisposed)
_isSaving = false;
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
-115
View File
@@ -1,115 +0,0 @@
@page "/import"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@using Core.Parsers
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@inject AppDbContext Context
@rendermode InteractiveServer
<PageTitle>Import Data</PageTitle>
<h1>Import Data</h1>
<h3>Events</h3>
<InputFile OnChange="UploadEvents"></InputFile>
<text>@_events?.Length Events</text>
<button class="btn btn-primary" @onclick="SaveEvents">Save to Database</button>
<br/>
<h3>Students</h3>
<InputFile OnChange="UploadStudents"></InputFile>
<text>@_students?.Length Students</text>
<button class="btn btn-primary" @onclick="SaveStudents">Save to Database</button>
@code {
private EventDefinition[]? _events;
private Student[]? _students;
async Task UploadEvents(InputFileChangeEventArgs arg)
{
await GetStreamReaderFromInputFile(arg, reader =>
{
var eventDefinitionParser = new EventDefinitionParser(reader);
_events = eventDefinitionParser.Parse();
});
}
async Task SaveEvents()
{
if (_events == null)
return;
foreach (var evt in _events)
{
// check if it already exists
var exists
= await Context.Events
.FirstOrDefaultAsync(e => e.Name == evt.Name);
if (exists != null)
continue;
await Context.Events.AddAsync(evt);
}
await Context.SaveChangesAsync();
}
async Task UploadStudents(InputFileChangeEventArgs arg)
{
await GetStreamReaderFromInputFile(arg, reader =>
{
var studentParser = new StudentParser(reader);
_students = studentParser.Parse();
});
}
async Task SaveStudents()
{
if (_students == null)
return;
try
{
foreach (var student in _students)
{
// check if it already exists
var exists
= await Context.Students
.FirstOrDefaultAsync(e
=> e.FirstName == student.FirstName
&& e.LastName == student.LastName);
if (exists != null)
continue;
await Context.Students.AddAsync(student);
}
await Context.SaveChangesAsync();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
static async Task GetStreamReaderFromInputFile(InputFileChangeEventArgs arg, Action<StreamReader> f)
{
StreamReader? streamReader = null;
try
{
var browserFile = arg.File;
await using var fs = browserFile.OpenReadStream();
await using var ms = new MemoryStream();
await fs.CopyToAsync(ms);
ms.Seek(0,0);
streamReader = new StreamReader(ms);
f(streamReader);
}
catch
{
streamReader?.Dispose();
throw;
}
}
}
+24
View File
@@ -1,4 +1,6 @@
using Core.Models;
using Core.Notes;
using Microsoft.Extensions.Configuration;
namespace WebApp.Models;
@@ -51,4 +53,26 @@ public class ChapterSettings
/// School level for the chapter (null = import both MS and HS events)
/// </summary>
public SchoolLevel? SchoolLevel { get; set; }
/// <summary>
/// Field names from student note <c>## Imported 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())];
}
}
+12 -5
View File
@@ -6,6 +6,8 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using Serilog;
using System.Text.Json;
using System.Text.Json.Nodes;
using Core.Notes;
using VisNetwork.Blazor;
using WebApp;
using WebApp.Authentication;
@@ -34,21 +36,24 @@ if (!File.Exists(dataAppSettingsPath))
var baseConfig = File.ReadAllText(baseAppSettingsPath);
var baseDoc = JsonDocument.Parse(baseConfig);
var templateSettings = new Dictionary<string, object?>();
JsonObject templateSettings = [];
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
{
templateSettings["ChapterSettings"] = JsonSerializer.Deserialize<object>(chapterSettings.GetRawText());
var chapterObj = JsonNode.Parse(chapterSettings.GetRawText()) as JsonObject ?? [];
if (chapterObj["StudentIndexNoteFields"] is null)
chapterObj["StudentIndexNoteFields"] = JsonSerializer.SerializeToNode(StudentNoteFieldDefaults.IndexColumns);
templateSettings["ChapterSettings"] = chapterObj;
}
if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings))
{
templateSettings["ValidationSettings"] = JsonSerializer.Deserialize<object>(validationSettings.GetRawText());
templateSettings["ValidationSettings"] = JsonNode.Parse(validationSettings.GetRawText());
}
if (templateSettings.Any())
if (templateSettings.Count > 0)
{
var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions
var json = templateSettings.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
});
@@ -206,6 +211,8 @@ builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Servic
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.AddScoped<Core.Services.IStudentNotesImportService, Core.Services.StudentNotesImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentNotesImportSaveService, WebApp.Services.StudentNotesImportSaveService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
+23 -11
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using WebApp.Models;
namespace WebApp.Services;
@@ -8,6 +9,11 @@ namespace WebApp.Services;
/// </summary>
public class ChapterSettingsWriter : IChapterSettingsWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly ILogger<ChapterSettingsWriter> _logger;
@@ -29,35 +35,41 @@ public class ChapterSettingsWriter : IChapterSettingsWriter
var appSettingsPath = GetAppSettingsPath();
var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
Dictionary<string, object?> root;
JsonObject root;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? [];
root = JsonNode.Parse(existingJson) as JsonObject ?? [];
}
else
{
root = [];
}
root["ChapterSettings"] = settings;
var incoming = JsonSerializer.SerializeToNode(settings, JsonOptions) as JsonObject ?? [];
if (root["ChapterSettings"] is JsonObject existing)
{
foreach (var property in incoming)
existing[property.Key] = property.Value?.DeepClone();
}
else
{
root["ChapterSettings"] = incoming;
}
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(root, options);
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
await File.WriteAllTextAsync(appSettingsPath, root.ToJsonString(JsonOptions), cancellationToken);
if (_configuration is IConfigurationRoot configurationRoot)
configurationRoot.Reload();
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
}
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
{
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
?? new ChapterSettings();
var settings = ChapterSettings.FromConfiguration(_configuration);
settings.CompetitionYear = competitionYear;
await WriteAsync(settings, cancellationToken);
}
+20
View File
@@ -22,6 +22,26 @@ public interface INotesService
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
/// Gets the system note for a student, or null if none exists.
/// </summary>
Task<Note?> GetStudentNoteAsync(int studentId);
/// <summary>
/// Gets system notes for the given student ids, keyed by student id.
/// </summary>
Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds);
/// <summary>
/// Soft-deletes system notes for the given student ids.
/// </summary>
Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default);
/// <summary>
/// Distinct Field names from student note <c>## Imported fields</c> tables.
/// </summary>
Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all history entries for a note.
/// </summary>
@@ -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; }
}
+85 -5
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Notes;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
@@ -46,8 +47,9 @@ public class NotesService : INotesService
}
return await query
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
@@ -67,6 +69,84 @@ public class NotesService : INotesService
.FirstOrDefaultAsync();
}
public async Task<Note?> GetStudentNoteAsync(int studentId)
{
var title = _noteNamingService.GetStudentNoteTitle(studentId);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == title && !n.IsDeleted)
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<int, Note>();
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.AsNoTracking()
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync();
Dictionary<int, Note> byStudentId = [];
foreach (var note in notes)
{
if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId))
byStudentId[studentId] = note;
}
return byStudentId;
}
public async Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return;
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync(cancellationToken);
if (notes.Count == 0)
return;
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
foreach (var note in notes)
{
_context.NoteHistories.Add(new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Soft Deleted"
});
note.IsDeleted = true;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Soft-deleted {Count} student note(s)", notes.Count);
}
public async Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default)
{
var contents = await _context.Notes
.AsNoTracking()
.Where(n => !n.IsDeleted && n.Title != null && n.Title.StartsWith("#Student:"))
.Select(n => n.Content)
.ToListAsync(cancellationToken);
return ImportedFieldsTable.DistinctFieldNames(contents);
}
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{
return await _context.NoteHistories
@@ -253,9 +333,9 @@ public class NotesService : INotesService
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsDeleted)
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
@@ -0,0 +1,64 @@
using Core.Models;
using Core.Services;
namespace WebApp.Services;
/// <summary>
/// Creates or updates #Student:{id} notes when imported fields actually change.
/// </summary>
public class StudentNotesImportSaveService : IStudentNotesImportSaveService
{
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
private readonly ILogger<StudentNotesImportSaveService> _logger;
public StudentNotesImportSaveService(
INotesService notesService,
INoteNamingService noteNamingService,
ILogger<StudentNotesImportSaveService> logger)
{
_notesService = notesService;
_noteNamingService = noteNamingService;
_logger = logger;
}
/// <inheritdoc />
public async Task<StudentNotesImportSaveResult> SaveAsync(
StudentNotesImportResult parseResult,
CancellationToken cancellationToken = default)
{
var result = new StudentNotesImportSaveResult();
var existingNotes = await _notesService.GetStudentNotesAsync(
parseResult.Matches.Select(m => m.Student.Id));
var actions = StudentNotesImportPlan.Create(parseResult, existingNotes.Keys.ToHashSet());
result.StudentsUnchanged = parseResult.Matches.Count - actions.Count;
foreach (var action in actions)
{
cancellationToken.ThrowIfCancellationRequested();
if (action.Kind == StudentNotePersistKind.Update
&& existingNotes.TryGetValue(action.StudentId, out var existing))
{
existing.Content = action.Markdown;
await _notesService.UpdateNoteAsync(existing);
result.NotesUpdated++;
continue;
}
await _notesService.CreateNoteAsync(new Core.Entities.Note
{
Title = _noteNamingService.GetStudentNoteTitle(action.StudentId),
Content = action.Markdown
});
result.NotesCreated++;
}
_logger.LogInformation(
"Student notes import saved. Created={Created}, Updated={Updated}, Unchanged={Unchanged}",
result.NotesCreated,
result.NotesUpdated,
result.StudentsUnchanged);
return result;
}
}
+5
View File
@@ -12,17 +12,20 @@ public class YearRolloverService : IYearRolloverService
private readonly AppDbContext _context;
private readonly IDatabaseBackupService _backupService;
private readonly IChapterSettingsWriter _chapterSettingsWriter;
private readonly INotesService _notesService;
private readonly ILogger<YearRolloverService> _logger;
public YearRolloverService(
AppDbContext context,
IDatabaseBackupService backupService,
IChapterSettingsWriter chapterSettingsWriter,
INotesService notesService,
ILogger<YearRolloverService> logger)
{
_context = context;
_backupService = backupService;
_chapterSettingsWriter = chapterSettingsWriter;
_notesService = notesService;
_logger = logger;
}
@@ -107,6 +110,8 @@ public class YearRolloverService : IYearRolloverService
$"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again.");
}
await _notesService.SoftDeleteStudentNotesAsync(removalIds, cancellationToken);
_context.Students.RemoveRange(toRemove);
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
+21
View File
@@ -0,0 +1,21 @@
window.tsaDownload = {
/**
* Triggers a browser file download from a base64 payload.
*/
fromBase64: function (filename, contentType, base64) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++)
bytes[i] = binary.charCodeAt(i);
var blob = new Blob([bytes], { type: contentType });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
};