@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 Logger @rendermode InteractiveServer Upload CSV Required columns: Student Name, Grade, TSA year. Optional IDs are saved on new students. Every other column is merged into that student's notes. @if (!string.IsNullOrEmpty(_fileName)) { @_fileName } Parse Clear Parsed Results @if (_isParsing) { Parsing... } else if (!string.IsNullOrEmpty(_parseError)) { @_parseError } else if (_students is null) { Upload and parse a CSV to see results here } else { @_students.Length student(s) parsed. @_newStudentCount new, @_existingStudentCount already in the database. @if (_leftoverFieldNames.Count > 0) { Leftover note fields: @string.Join(", ", _leftoverFieldNames) } else { No leftover note fields in this file. } Save to Database } @code { private byte[]? _fileBytes; private string? _fileName; private Student[]? _students; private List _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 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; } }