Add shared export infrastructure

This commit is contained in:
2026-08-18 21:21:56 +02:00
parent 41bf2c0756
commit 26461bd2f2
13 changed files with 336 additions and 63 deletions
+1
View File
@@ -110,6 +110,7 @@ public static class AppBootstrapper
EnsureLogger();
services.AddSingleton(Logger);
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
+45
View File
@@ -0,0 +1,45 @@
using System.Text;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Erzeugt semikolongetrennte CSV-Daten für die deutsche Excel-Umgebung und maskiert Felder
/// nach RFC-4180-Regeln. Das Trennzeichen bleibt bewusst konfigurierbar für weitere Exportformate.
/// </summary>
public sealed class CsvBuilder(char separator = ';')
{
private readonly StringBuilder _content = new();
public CsvBuilder AddRow(params object?[] values)
{
for (var index = 0; index < values.Length; index++)
{
if (index > 0) _content.Append(separator);
AppendEscaped(values[index]?.ToString() ?? "");
}
_content.AppendLine();
return this;
}
public CsvBuilder AddBlankRow()
{
_content.AppendLine();
return this;
}
public override string ToString() => _content.ToString();
private void AppendEscaped(string value)
{
if (!value.Contains(separator) && !value.Contains('"') &&
!value.Contains('\r') && !value.Contains('\n'))
{
_content.Append(value);
return;
}
_content.Append('"');
_content.Append(value.Replace("\"", "\"\"", StringComparison.Ordinal));
_content.Append('"');
}
}
@@ -0,0 +1,75 @@
using Avalonia.Platform.Storage;
using System.Text;
namespace LehrerApp.Desktop.Services;
public sealed record ExportFormat(string Label, string Extension, string MimeType)
{
public static ExportFormat Csv { get; } = new("CSV-Dateien", ".csv", "text/csv");
public static ExportFormat Json { get; } = new("JSON-Dateien", ".json", "application/json");
}
public sealed record ExportFile(
string DialogTitle,
string SuggestedFileName,
ExportFormat Format,
ReadOnlyMemory<byte> Content)
{
public static ExportFile Csv(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Csv, content, includeUtf8Bom: true);
public static ExportFile Json(string dialogTitle, string suggestedFileName, string content) =>
FromText(dialogTitle, suggestedFileName, ExportFormat.Json, content);
private static ExportFile FromText(string dialogTitle, string suggestedFileName,
ExportFormat format, string content, bool includeUtf8Bom = false)
{
var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: includeUtf8Bom);
var preamble = encoding.GetPreamble();
var text = encoding.GetBytes(content);
var bytes = new byte[preamble.Length + text.Length];
preamble.CopyTo(bytes, 0);
text.CopyTo(bytes, preamble.Length);
return new ExportFile(dialogTitle,
EnsureExtension(SanitizeFileName(suggestedFileName), format.Extension), format, bytes);
}
private static string EnsureExtension(string fileName, string extension) =>
fileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ? fileName : fileName + extension;
private static string SanitizeFileName(string fileName)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
return string.Concat(fileName.Select(character =>
invalidCharacters.Contains(character) ? '_' : character));
}
}
/// <summary>Zentrale Dateiauswahl und Stream-Ausgabe für alle Exportformate.</summary>
public sealed class ExportService
{
public async Task<bool> SaveAsync(IStorageProvider storageProvider, ExportFile export)
{
var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = export.DialogTitle,
SuggestedFileName = export.SuggestedFileName,
DefaultExtension = export.Format.Extension.TrimStart('.'),
FileTypeChoices =
[
new FilePickerFileType(export.Format.Label)
{
Patterns = [$"*{export.Format.Extension}"],
MimeTypes = [export.Format.MimeType],
},
],
});
if (file is null) return false;
await using var stream = await file.OpenWriteAsync();
if (stream.CanSeek) stream.SetLength(0);
await stream.WriteAsync(export.Content);
return true;
}
}
@@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -195,27 +195,27 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
public string ExportCsv()
{
var sb = new StringBuilder();
sb.AppendLine($"Klausur;{_exam.Title}");
sb.AppendLine($"Datum;{_exam.Date:dd.MM.yyyy}");
sb.AppendLine();
sb.AppendLine("Notenspiegel");
sb.AppendLine("Note;Anzahl;Anteil");
var csv = new CsvBuilder()
.AddRow("Klausur", _exam.Title)
.AddRow("Datum", _exam.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture))
.AddBlankRow()
.AddRow("Notenspiegel")
.AddRow("Note", "Anzahl", "Anteil");
foreach (var g in GradeDistribution)
sb.AppendLine($"{g.Grade};{g.Count};{Percent(g.Count, GradedCount)}");
sb.AppendLine();
sb.AppendLine($"Durchschnitt;{AverageDisplay}");
sb.AppendLine($"Median;{MedianDisplay}");
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) sb.AppendLine($"{BelowThresholdLabel1};{BelowThresholdDisplay1}");
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) sb.AppendLine($"{BelowThresholdLabel2};{BelowThresholdDisplay2}");
sb.AppendLine($"Bewertet;{GradedCount}");
sb.AppendLine($"Abwesend;{AbsentCount}");
sb.AppendLine();
sb.AppendLine("Aufgabenanalyse");
sb.AppendLine("Aufgabe;Ø Erfüllungsgrad;Auffällig schwach");
csv.AddRow(g.Grade, g.Count, Percent(g.Count, GradedCount));
csv.AddBlankRow()
.AddRow("Durchschnitt", AverageDisplay)
.AddRow("Median", MedianDisplay);
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1);
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2);
csv.AddRow("Bewertet", GradedCount)
.AddRow("Abwesend", AbsentCount)
.AddBlankRow()
.AddRow("Aufgabenanalyse")
.AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach");
foreach (var t in TaskAnalysis)
sb.AppendLine($"{t.Label};{t.AvgPercentDisplay};{(t.IsWeak ? "ja" : "")}");
return sb.ToString();
csv.AddRow(t.Label, t.AvgPercentDisplay, t.IsWeak ? "ja" : "");
return csv.ToString();
}
}
@@ -3,9 +3,9 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -197,12 +197,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject
public string ExportCsv()
{
var sb = new StringBuilder();
sb.AppendLine($"Zeugnisnoten;{_groupLabel};{SelectedPeriod.Label}");
sb.AppendLine("Schüler;Berechnet;Übersteuert;Begründung;Endnote;Gesperrt");
var csv = new CsvBuilder()
.AddRow("Zeugnisnoten", _groupLabel, SelectedPeriod.Label)
.AddRow("Schüler", "Berechnet", "Übersteuert", "Begründung", "Endnote", "Gesperrt");
foreach (var r in Rows)
sb.AppendLine($"{r.Name};{r.CalculatedValue};{r.OverrideValue};{r.OverrideReason};{r.FinalDisplay};{(r.IsLocked ? "ja" : "")}");
return sb.ToString();
csv.AddRow(r.Name, r.CalculatedValue, r.OverrideValue, r.OverrideReason,
r.FinalDisplay, r.IsLocked ? "ja" : "");
return csv.ToString();
}
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -519,6 +520,7 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
public class CategoryTimeSummary(string category, int minutes, double barFraction)
{
public string Category { get; } = category;
public int Minutes { get; } = minutes;
public string MinutesDisplay { get; } = $"{minutes} min";
public double BarFraction { get; } = barFraction;
}
@@ -619,6 +621,20 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
public ObservableCollection<GroupTimeSummary> GroupSummaries { get; } = [];
public string TotalMinutesDisplay { get; private set; } = "0 min";
public string RequiredVsActualDisplay { get; private set; } = "";
public string ExportSuggestedFileName
{
get
{
if (PeriodMode == SchoolYearMode)
return $"Arbeitszeitauswertung_Schuljahr_{SelectedYear}-{SelectedYear + 1}.csv";
var month = MonthOptions.IndexOf(SelectedMonth) + 1;
return $"Arbeitszeitauswertung_{SelectedYear}-{month:00}.csv";
}
}
private DateOnly _periodFrom;
private DateOnly _periodTo;
private int _totalMinutes;
public WorkloadEvaluationViewModel(ITimeEntryRepository entries, IGroupRepository groups,
WorkloadSettingsService workloadSettings, SchoolYearService schoolYear)
@@ -671,6 +687,8 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
private void Refresh()
{
var (from, to) = CurrentPeriod();
_periodFrom = from;
_periodTo = to;
var periodEntries = _entries.GetByDateRange(from, to);
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
@@ -698,6 +716,7 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
}
var totalMinutes = periodEntries.Sum(e => e.DurationMinutes);
_totalMinutes = totalMinutes;
TotalMinutesDisplay = $"{totalMinutes} min ({(totalMinutes / 60.0).ToString("0.#", CultureInfo.InvariantCulture)} h)";
// Pflichtstunden-Abgleich (6.3.2): auf die Anzahl Wochen im Zeitraum hochgerechnet.
@@ -721,12 +740,52 @@ public partial class WorkloadEvaluationViewModel : ObservableObject
OnPropertyChanged(nameof(TotalMinutesDisplay));
OnPropertyChanged(nameof(RequiredVsActualDisplay));
OnPropertyChanged(nameof(ExportSuggestedFileName));
}
public string ExportCsv()
{
var germanCulture = CultureInfo.GetCultureInfo("de-DE");
var csv = new CsvBuilder()
.AddRow("Arbeitszeitauswertung")
.AddRow("Zeitraum", _periodFrom.ToString("dd.MM.yyyy", germanCulture),
_periodTo.ToString("dd.MM.yyyy", germanCulture))
.AddRow("Gesamtzeit (Minuten)", _totalMinutes)
.AddRow("Gesamtzeit (Stunden)", (_totalMinutes / 60.0).ToString("0.##", germanCulture));
if (_workloadSettings.RequiredWeeklyHours > 0)
{
var weeks = (_periodTo.DayNumber - _periodFrom.DayNumber + 1) / 7.0;
var requiredHours = _workloadSettings.RequiredWeeklyHours * weeks;
var actualHours = _totalMinutes / 60.0;
csv.AddRow("Pflichtstunden pro Woche",
_workloadSettings.RequiredWeeklyHours.ToString("0.##", germanCulture))
.AddRow("Sollzeit (Stunden)", requiredHours.ToString("0.##", germanCulture))
.AddRow("Abweichung (Stunden)", (actualHours - requiredHours).ToString("0.##", germanCulture));
}
csv.AddBlankRow()
.AddRow("Nach Kategorie")
.AddRow("Kategorie", "Minuten", "Stunden");
foreach (var summary in CategorySummaries)
csv.AddRow(summary.Category, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
csv.AddBlankRow()
.AddRow("Nach Gruppe")
.AddRow("Gruppe", "Minuten", "Stunden");
foreach (var summary in GroupSummaries)
csv.AddRow(summary.GroupName, summary.Minutes,
(summary.Minutes / 60.0).ToString("0.##", germanCulture));
return csv.ToString();
}
}
public class GroupTimeSummary(string groupName, int minutes, double barFraction)
{
public string GroupName { get; } = groupName;
public int Minutes { get; } = minutes;
public string MinutesDisplay { get; } = $"{minutes} min";
public double BarFraction { get; } = barFraction;
}
@@ -1,7 +1,8 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -15,15 +16,9 @@ public partial class ExamEvaluationDialog : Window
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Klausurauswertung exportieren",
SuggestedFileName = $"Auswertung_{vm.ExamTitle}.csv",
FileTypeChoices = [new FilePickerFileType("CSV-Dateien") { Patterns = ["*.csv"] }],
});
if (file is null) return;
await File.WriteAllTextAsync(file.Path.LocalPath, vm.ExportCsv());
var export = ExportFile.Csv("Klausurauswertung exportieren",
$"Auswertung_{vm.ExamTitle}.csv", vm.ExportCsv());
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
@@ -1,7 +1,8 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
@@ -15,15 +16,9 @@ public partial class ReportGradeDialog : Window
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Zeugnisnoten exportieren",
SuggestedFileName = $"Zeugnisnoten_{vm.GroupLabel}.csv",
FileTypeChoices = [new FilePickerFileType("CSV-Dateien") { Patterns = ["*.csv"] }],
});
if (file is null) return;
await File.WriteAllTextAsync(file.Path.LocalPath, vm.ExportCsv());
var export = ExportFile.Csv("Zeugnisnoten exportieren",
$"Zeugnisnoten_{vm.GroupLabel}.csv", vm.ExportCsv());
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
private void OnClose(object? sender, RoutedEventArgs e) => Close();
@@ -7,12 +7,14 @@
<StackPanel Margin="20,16" Spacing="16">
<!-- Zeitraum -->
<Grid ColumnDefinitions="Auto,Auto,Auto" HorizontalAlignment="Left">
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto" HorizontalAlignment="Left">
<ComboBox Grid.Column="0" ItemsSource="{Binding PeriodModeOptions}"
SelectedItem="{Binding PeriodMode}" Margin="0,0,8,0"/>
<ComboBox Grid.Column="1" ItemsSource="{Binding MonthOptions}" SelectedItem="{Binding SelectedMonth}"
Margin="0,0,8,0" IsVisible="{Binding IsMonthMode}"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding YearOptions}" SelectedItem="{Binding SelectedYear}"/>
<Button Grid.Column="3" Content="Als CSV exportieren" Margin="16,0,0,0"
Click="OnExportClick"/>
</Grid>
<!-- Pflichtstunden (6.3.2) -->
@@ -1,8 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Workload;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Workload;
public partial class WorkloadEvaluationView : UserControl
{
public WorkloadEvaluationView() => InitializeComponent();
private async void OnExportClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not WorkloadEvaluationViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var export = ExportFile.Csv("Arbeitszeitauswertung exportieren",
vm.ExportSuggestedFileName, vm.ExportCsv());
await App.Services.GetRequiredService<ExportService>().SaveAsync(topLevel.StorageProvider, export);
}
}