Files
LehrerApp/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs
T

148 lines
5.2 KiB
C#

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();
}
}