Jahresplanimport und Ablgeich von Untis

This commit is contained in:
2026-08-23 19:23:05 +02:00
parent 4a59bff9de
commit dd5ecb0334
20 changed files with 1039 additions and 20 deletions
@@ -0,0 +1,87 @@
using System.Text.Json;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Services;
internal sealed class AnnualPlanSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
}
/// <summary>
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
/// </summary>
public sealed class AnnualPlanSettingsService
{
private readonly string _configPath;
private readonly byte[] _urlKey;
private AnnualPlanSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public AnnualPlanSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
_config = Load();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() => _config.EncryptedIcalUrl is null
? null
: SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
Save();
}
private byte[] GenerateAndSaveKey(string keyPath)
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
}
private AnnualPlanSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
?? new AnnualPlanSettingsConfig();
}
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
return new AnnualPlanSettingsConfig();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
@@ -0,0 +1,147 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
{
public int ChangeCount => Added + Updated + Deleted;
}
/// <summary>
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
/// </summary>
public sealed class AnnualPlanSyncService : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
private readonly HttpClient _http;
private readonly AnnualPlanSettingsService _settings;
private readonly IAnnualPlanEventRepository _events;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
public event Action? DataChanged;
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
IAnnualPlanEventRepository events, AppLogger? logger = null)
{
_http = http;
_settings = settings;
_events = events;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrWhiteSpace(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
AnnualPlanPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
if (result.ChangeCount > 0) DataChanged?.Invoke();
}
finally
{
_gate.Release();
}
}
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
public AnnualPlanPollResult ProcessIcsText(string icsText)
{
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
var imported = AnnualPlanIcsParser.Parse(icsText);
var existing = _events.GetAll();
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
var added = 0;
var updated = 0;
foreach (var entry in imported)
{
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
{
_events.Save(entry);
added++;
continue;
}
entry.Id = previous.Id;
if (SameContent(previous, entry)) continue;
_events.Save(entry);
updated++;
}
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
foreach (var entry in stale) _events.Delete(entry.Id);
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
}
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
left.ExternalId == right.ExternalId &&
left.Title == right.Title &&
left.Description == right.Description &&
left.Location == right.Location &&
left.StartDate == right.StartDate &&
left.EndDate == right.EndDate &&
left.StartTime == right.StartTime &&
left.EndTime == right.EndTime &&
left.IsAllDay == right.IsAllDay &&
left.CalendarGroup == right.CalendarGroup &&
left.Color == right.Color &&
left.Status == right.Status &&
left.Sequence == right.Sequence &&
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
private static bool SameInstant(DateTime? left, DateTime? right) =>
left is null && right is null ||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
public void Clear()
{
var existing = _events.GetAll();
foreach (var entry in existing) _events.Delete(entry.Id);
if (existing.Count > 0) DataChanged?.Invoke();
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}