Jahresplanimport und Ablgeich von Untis
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user