@page "/print" @attribute [Authorize] @implements IAsyncDisposable @using Core.Printing @using Core.Services @inject INotesService NotesService @inject INoteNamingService NoteNamingService @inject INotePrintService NotePrintService @inject IPrintPresetService PrintPresetService @inject IConfiguration Configuration @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IJSRuntime JSRuntime @inject ClipboardService ClipboardService
@if (_isLoading) { } @foreach (var preset in _presets) { @preset.Name } Save as Update Delete Students Teams Events @if (_templateNotes.Count == 0) { Create a standalone note on Notes to use as a template. } else { @foreach (var note in _templateNotes) { @note.Title } } @if (_templateWarning is not null) { @_templateWarning } @if (_entityType == PrintEntityType.Student) { Any Officers only Non-officers only } else if (_entityType == PrintEntityType.Team) { } else { @foreach (var format in Enum.GetValues()) { @format } Any Regional only Non-regional only } Preview Print @if (!_previewStale && _pages.Count > 0) { @_pages.Count page@(_pages.Count == 1 ? "" : "s") } else if (!_previewStale && _didPreview) { No matches } Tokens (click to copy) @foreach (var group in TokenGroups) { @group.Label
@foreach (var token in group.Tokens) { @FormatToken(token) }
}
@if (_didPreview && _pages.Count == 0 && !_previewStale) { No matching records for these filters. } else { @for (var i = 0; i < _pages.Count; i++) { var page = _pages[i]; var isLast = i == _pages.Count - 1; var pageClass = _newPagePerRecord && !isLast ? "note-print-page pagebreak" : "note-print-page";
@((MarkupString)page.Html)
} } @code { private CancellationTokenSource? _cancellationTokenSource; private bool _isDisposed; private bool _isLoading = true; private bool _isBusy; private bool _previewStale = true; private bool _didPreview; private List _templateNotes = []; private List _presets = []; private List _importedTokenNames = []; private IReadOnlyList _pages = []; private int? _selectedPresetId; private int? _selectedNoteId; private string _saveAsName = string.Empty; private string? _templateWarning; private PrintEntityType _entityType = PrintEntityType.Student; private int? _grade; private int? _tsaYear; private string _officerChoice = "any"; private string? _teamIdentifierContains; private string? _eventNameContains; private EventFormat? _eventFormat; private string _regionalChoice = "any"; private bool _newPagePerRecord = true; private int _fontSizePt = PrintPresetFilters.DefaultFontSizePt; private int _answerSpaceLines = PrintPresetFilters.DefaultAnswerSpaceLines; private static string FormatToken(string token) => "{{" + token + "}}"; private string PrintPageStyle => $"--print-font-size:{_fontSizePt}pt;--print-answer-lines:{_answerSpaceLines};"; private IEnumerable<(string Label, IReadOnlyList 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 && _importedTokenNames.Count > 0) yield return ("Additional fields", _importedTokenNames); } } private bool CanPreview => _selectedNoteId.HasValue && SelectedTemplateNote is { Content: not null } note && !string.IsNullOrWhiteSpace(note.Content) && !note.IsDeleted && _templateWarning is null; private bool CanSaveAs => CanPreview && !string.IsNullOrWhiteSpace(_saveAsName); private Note? SelectedTemplateNote => _templateNotes.FirstOrDefault(n => n.Id == _selectedNoteId); protected override void OnInitialized() { _cancellationTokenSource = new CancellationTokenSource(); } protected override async Task OnInitializedAsync() { await LoadAsync(); } private async Task LoadAsync() { if (_isDisposed) return; _isLoading = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var notes = await NotesService.GetNotesAsync(); _templateNotes = [ .. notes.Where(n => !NoteNamingService.IsPageNote(n.Title)) .OrderBy(n => n.Title) ]; await RefreshImportedTokenNamesAsync(token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } finally { if (!_isDisposed) _isLoading = false; } } private void MarkStale() { _previewStale = true; _pages = []; } private void OnEntityTypeChanged(PrintEntityType value) { _entityType = value; _templateWarning = null; MarkStale(); } private void OnNoteSelected(int? value) { _selectedNoteId = value; _templateWarning = null; MarkStale(); } private void OnGradeChanged(int? value) { _grade = value; MarkStale(); } private void OnTsaYearChanged(int? value) { _tsaYear = value; MarkStale(); } private void OnOfficerChanged(string value) { _officerChoice = value; MarkStale(); } private void OnTeamIdentifierChanged(string? value) { _teamIdentifierContains = value; MarkStale(); } private void OnEventNameChanged(string? value) { _eventNameContains = value; MarkStale(); } private void OnEventFormatChanged(EventFormat? value) { _eventFormat = value; MarkStale(); } private void OnRegionalChanged(string value) { _regionalChoice = value; MarkStale(); } private void OnNewPagePerRecordChanged(bool value) { _newPagePerRecord = value; } private void OnFontSizeChanged(int value) { _fontSizePt = Math.Clamp(value, PrintPresetFilters.MinFontSizePt, PrintPresetFilters.MaxFontSizePt); } private void OnAnswerSpaceLinesChanged(int value) { _answerSpaceLines = Math.Clamp(value, PrintPresetFilters.MinAnswerSpaceLines, PrintPresetFilters.MaxAnswerSpaceLines); } private async Task RefreshImportedTokenNamesAsync(CancellationToken token) { var indexFields = WebApp.Models.ChapterSettings.ReadIndexNoteFields(Configuration); var discovered = await NotesService.GetImportedFieldNamesAsync(token); _importedTokenNames = [ .. indexFields .Concat(discovered) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) ]; } private async Task OnPresetSelected(int? id) { _selectedPresetId = id; if (!id.HasValue) { MarkStale(); return; } var preset = _presets.FirstOrDefault(p => p.Id == id.Value); if (preset is null) return; ApplyPreset(preset); MarkStale(); await Task.CompletedTask; } private void ApplyPreset(PrintPreset preset) { _saveAsName = preset.Name; _entityType = preset.EntityType; var filters = PrintPresetFilters.FromJson(preset.FiltersJson); _grade = filters.Grade; _tsaYear = filters.TsaYear; _officerChoice = PrintPresetFilters.ToTriState(filters.IsOfficer); _teamIdentifierContains = filters.TeamIdentifierContains; _eventNameContains = filters.EventNameContains; _eventFormat = filters.EventFormat; _regionalChoice = PrintPresetFilters.ToTriState(filters.RegionalOnly); _newPagePerRecord = filters.NewPagePerRecord; _fontSizePt = filters.FontSizePt; _answerSpaceLines = filters.AnswerSpaceLines; var note = _templateNotes.FirstOrDefault(n => n.Id == preset.NoteId); if (note is null || note.IsDeleted || string.IsNullOrWhiteSpace(note.Content)) { _selectedNoteId = null; _templateWarning = "This preset's template note is missing or empty. Choose another note."; } else { _selectedNoteId = note.Id; _templateWarning = null; } } private PrintPresetFilters BuildFilters() => new() { Grade = _entityType == PrintEntityType.Student ? _grade : null, TsaYear = _entityType == PrintEntityType.Student ? _tsaYear : null, IsOfficer = _entityType == PrintEntityType.Student ? PrintPresetFilters.FromTriState(_officerChoice) : null, TeamIdentifierContains = _entityType == PrintEntityType.Team ? _teamIdentifierContains : null, EventNameContains = _entityType == PrintEntityType.Event ? _eventNameContains : null, EventFormat = _entityType == PrintEntityType.Event ? _eventFormat : null, RegionalOnly = _entityType == PrintEntityType.Event ? PrintPresetFilters.FromTriState(_regionalChoice) : null, NewPagePerRecord = _newPagePerRecord, FontSizePt = _fontSizePt, AnswerSpaceLines = _answerSpaceLines }; private async Task Preview() { if (_isDisposed || !CanPreview) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await RefreshImportedTokenNamesAsync(token); var note = SelectedTemplateNote!; _pages = await NotePrintService.PreviewAsync( new NotePrintRequest { EntityType = _entityType, TemplateMarkdown = note.Content ?? string.Empty, Filters = BuildFilters(), ImportedFieldCatalog = _importedTokenNames }, token); _previewStale = false; _didPreview = true; if (_pages.Count > 75 && !_isDisposed) { Snackbar.Add( $"This preview has {_pages.Count} pages. Printing a large set can be slow.", Severity.Warning); } } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Preview failed: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task Print() { if (_isDisposed || _previewStale || _pages.Count == 0) return; try { await JSRuntime.InvokeVoidAsync("window.print"); } catch (JSDisconnectedException) { } catch (TaskCanceledException) { } } private async Task CopyToken(string token) { if (_isDisposed) return; try { await ClipboardService.WriteTextAsync($"{{{{{token}}}}}"); if (!_isDisposed) Snackbar.Add($"Copied {FormatToken(token)}", Severity.Info); } catch (JSDisconnectedException) { } catch (TaskCanceledException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not copy: {ex.Message}", Severity.Error); } } private async Task SaveAsPreset() { if (_isDisposed || !CanSaveAs || !_selectedNoteId.HasValue) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var name = _saveAsName.Trim(); if (await PrintPresetService.NameExistsAsync(name, null, token)) { Snackbar.Add($"A print preset named '{name}' already exists.", Severity.Warning); return; } var created = await PrintPresetService.CreateAsync( new PrintPreset { Name = name, NoteId = _selectedNoteId.Value, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = created.Id; if (!_isDisposed) Snackbar.Add($"Saved print preset '{name}'.", Severity.Success); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not save: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task UpdatePreset() { if (_isDisposed || !_selectedPresetId.HasValue || !_selectedNoteId.HasValue || !CanPreview) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value); if (existing is null) return; await PrintPresetService.UpdateAsync( new PrintPreset { Id = existing.Id, Name = existing.Name, NoteId = _selectedNoteId.Value, EntityType = _entityType, FiltersJson = BuildFilters().ToJson() }, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; if (!_isDisposed) Snackbar.Add($"Updated print preset '{existing.Name}'.", Severity.Success); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not update: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } private async Task DeletePreset() { if (_isDisposed || !_selectedPresetId.HasValue) return; var existing = _presets.FirstOrDefault(p => p.Id == _selectedPresetId.Value); if (existing is null) return; var confirmed = await DialogService.ShowMessageBox( "Delete print preset", (MarkupString)$"Delete {existing.Name}? This cannot be undone.", yesText: "Delete", cancelText: "Cancel"); if (confirmed != true || _isDisposed) return; _isBusy = true; try { var token = _cancellationTokenSource?.Token ?? CancellationToken.None; await PrintPresetService.DeleteAsync(existing.Id, token); _presets = [.. await PrintPresetService.GetAllAsync(token)]; _selectedPresetId = null; if (!_isDisposed) Snackbar.Add($"Deleted print preset '{existing.Name}'.", Severity.Info); } catch (TaskCanceledException) { } catch (JSDisconnectedException) { } catch (Exception ex) { if (!_isDisposed) Snackbar.Add($"Could not delete: {ex.Message}", Severity.Error); } finally { if (!_isDisposed) _isBusy = false; } } public async ValueTask DisposeAsync() { if (!_isDisposed) { _isDisposed = true; _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; } await ValueTask.CompletedTask; } }