WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry. Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für Stunden auf, die real unverändert sind — wird in einem Folge-Commit untersucht, deshalb vorerst auf diesem Branch statt main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
|
||||
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
|
||||
/// Stundenplan in der App zuordnet").</summary>
|
||||
public sealed class UntisWeeklyPattern
|
||||
{
|
||||
public DayOfWeek Weekday { get; init; }
|
||||
public TimeOnly StartTime { get; init; }
|
||||
public TimeOnly EndTime { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
|
||||
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
|
||||
public List<string> ClassTokens { get; init; } = [];
|
||||
public int OccurrenceCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UntisSlotMatch
|
||||
{
|
||||
public required UntisWeeklyPattern Pattern { get; init; }
|
||||
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
|
||||
/// entspricht CoveredPeriods[0].
|
||||
public int? PeriodNumber { get; init; }
|
||||
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
|
||||
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:50–09:20 für Stunde 1+2), während der
|
||||
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
|
||||
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
|
||||
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
|
||||
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
|
||||
public List<int> CoveredPeriods { get; init; } = [];
|
||||
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
|
||||
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
|
||||
public int? AfterPeriod { get; init; }
|
||||
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
|
||||
public Guid? SuggestedGroupId { get; init; }
|
||||
public bool IsConfident { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UntisMatchResult
|
||||
{
|
||||
public List<UntisSlotMatch> Matches { get; init; } = [];
|
||||
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
|
||||
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
|
||||
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
|
||||
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
|
||||
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
|
||||
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
|
||||
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
|
||||
/// </summary>
|
||||
public class UntisMatchingService
|
||||
{
|
||||
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
|
||||
// und den tatsächlichen WebUntis-Zeiten.
|
||||
private const int StartTimeToleranceMinutes = 3;
|
||||
|
||||
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
|
||||
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
|
||||
{
|
||||
var teacherToken = DetectTeacherToken(events);
|
||||
var patterns = BuildWeeklyPatterns(events, teacherToken);
|
||||
|
||||
var matches = new List<UntisSlotMatch>();
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
|
||||
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
|
||||
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
|
||||
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
|
||||
if (pattern.ClassTokens.Count == 0)
|
||||
{
|
||||
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
|
||||
continue;
|
||||
}
|
||||
|
||||
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
|
||||
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
|
||||
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
|
||||
matches.Add(new UntisSlotMatch
|
||||
{
|
||||
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
|
||||
SuggestedGroupId = groupId, IsConfident = confident,
|
||||
});
|
||||
}
|
||||
|
||||
var matchedSlotKeys = matches
|
||||
.Where(m => m.SuggestedGroupId is not null)
|
||||
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
|
||||
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
|
||||
.ToHashSet();
|
||||
var unmatchedSlots = timetableSlots
|
||||
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
|
||||
.ToList();
|
||||
|
||||
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
|
||||
}
|
||||
|
||||
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
|
||||
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
|
||||
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
|
||||
events
|
||||
.Select(e => LastToken(e.Description))
|
||||
.Where(t => t is not null)
|
||||
.GroupBy(t => t)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static string? LastToken(string description)
|
||||
{
|
||||
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length == 0 ? null : parts[^1];
|
||||
}
|
||||
|
||||
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
|
||||
{
|
||||
var withTokens = events.Select(e => new
|
||||
{
|
||||
Event = e,
|
||||
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
|
||||
});
|
||||
|
||||
var patterns = new List<UntisWeeklyPattern>();
|
||||
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
|
||||
{
|
||||
var modal = group
|
||||
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First();
|
||||
|
||||
patterns.Add(new UntisWeeklyPattern
|
||||
{
|
||||
Weekday = group.Key.Weekday,
|
||||
StartTime = group.Key.StartTime,
|
||||
EndTime = group.Key.EndTime,
|
||||
Summary = modal.Key.Summary,
|
||||
ClassTokens = modal.Key.ClassTokens.Length == 0
|
||||
? [] : modal.Key.ClassTokens.Split(';').ToList(),
|
||||
OccurrenceCount = modal.Count(),
|
||||
});
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
|
||||
private static List<string> ExtractClassTokens(string description, string? teacherToken)
|
||||
{
|
||||
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
|
||||
? description[..^teacherToken.Length].TrimEnd()
|
||||
: description;
|
||||
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
|
||||
return withoutTeacher.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
}
|
||||
|
||||
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
|
||||
{
|
||||
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
|
||||
if (exact is not null) return exact.PeriodNumber;
|
||||
|
||||
var closest = periodSchedule.Periods
|
||||
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
|
||||
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
|
||||
.OrderBy(t => t.DiffMinutes)
|
||||
.FirstOrDefault();
|
||||
return closest.Period?.PeriodNumber;
|
||||
}
|
||||
|
||||
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
|
||||
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
|
||||
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
|
||||
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
|
||||
{
|
||||
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
|
||||
return periodSchedule.Periods
|
||||
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
|
||||
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
|
||||
.OrderBy(p => p.PeriodNumber)
|
||||
.Select(p => p.PeriodNumber)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
|
||||
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
|
||||
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
|
||||
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
|
||||
periodSchedule.Periods
|
||||
.Where(p => p.End <= startTime)
|
||||
.OrderByDescending(p => p.End)
|
||||
.Select(p => (int?)p.PeriodNumber)
|
||||
.FirstOrDefault() ?? 0;
|
||||
|
||||
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
|
||||
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
|
||||
{
|
||||
if (classTokens.Count == 0) return (null, false);
|
||||
|
||||
var candidates = classTokens
|
||||
.Select(token => ResolveSingleGroup(token, groups))
|
||||
.Where(g => g is not null)
|
||||
.Select(g => g!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 1) return (candidates[0].Id, true);
|
||||
if (candidates.Count == 0) return (null, false);
|
||||
|
||||
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
|
||||
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
|
||||
if (periodNumber is { } period)
|
||||
{
|
||||
var withExistingSlot = candidates
|
||||
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
|
||||
.ToList();
|
||||
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
|
||||
}
|
||||
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
|
||||
}
|
||||
|
||||
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
|
||||
{
|
||||
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
|
||||
if (exact is not null) return exact;
|
||||
|
||||
var normalizedToken = Normalize(token);
|
||||
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
|
||||
}
|
||||
|
||||
private static string Normalize(string value) =>
|
||||
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
|
||||
}
|
||||
Reference in New Issue
Block a user