148 lines
5.4 KiB
C#
148 lines
5.4 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(CancellationToken cancellationToken = default)
|
|
{
|
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
|
try
|
|
{
|
|
var url = _settings.GetIcalUrl();
|
|
if (string.IsNullOrWhiteSpace(url)) return;
|
|
|
|
string icsText;
|
|
try
|
|
{
|
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
|
|
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
AnnualPlanPollResult result;
|
|
try
|
|
{
|
|
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
|
}
|
|
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();
|
|
}
|
|
}
|