Jahresplanimport und Ablgeich von Untis
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
|
||||
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
|
||||
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
|
||||
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
|
||||
/// </summary>
|
||||
public static class AnnualPlanIcsParser
|
||||
{
|
||||
private static readonly TimeZoneInfo BerlinTimeZone =
|
||||
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
private readonly record struct Property(string Head, string Value)
|
||||
{
|
||||
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static List<AnnualPlanEvent> Parse(string icsText)
|
||||
{
|
||||
var lines = Unfold(icsText);
|
||||
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
|
||||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
|
||||
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
|
||||
|
||||
var result = new List<AnnualPlanEvent>();
|
||||
Dictionary<string, Property>? current = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
|
||||
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
|
||||
result.Add(BuildEvent(current, result.Count + 1));
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null) continue;
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon < 0) continue;
|
||||
var head = line[..colon];
|
||||
var separator = head.IndexOf(';');
|
||||
var key = separator < 0 ? head : head[..separator];
|
||||
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
|
||||
}
|
||||
|
||||
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
|
||||
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
|
||||
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
|
||||
{
|
||||
var uid = Required(properties, "UID", number).Value.Trim();
|
||||
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
|
||||
|
||||
var startProperty = Required(properties, "DTSTART", number);
|
||||
var endProperty = properties.GetValueOrDefault("DTEND");
|
||||
|
||||
DateOnly startDate;
|
||||
DateOnly endDate;
|
||||
TimeOnly? startTime;
|
||||
TimeOnly? endTime;
|
||||
var isAllDay = startProperty.IsDate;
|
||||
|
||||
if (isAllDay)
|
||||
{
|
||||
startDate = ParseDate(startProperty.Value, number, "DTSTART");
|
||||
var endExclusive = endProperty == default
|
||||
? startDate.AddDays(1)
|
||||
: endProperty.IsDate
|
||||
? ParseDate(endProperty.Value, number, "DTEND")
|
||||
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
|
||||
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
|
||||
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
|
||||
startTime = null;
|
||||
endTime = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
|
||||
var end = endProperty == default
|
||||
? start
|
||||
: !endProperty.IsDate
|
||||
? ParseLocalDateTime(endProperty, number, "DTEND")
|
||||
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
|
||||
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
|
||||
startDate = DateOnly.FromDateTime(start);
|
||||
endDate = DateOnly.FromDateTime(end);
|
||||
startTime = TimeOnly.FromDateTime(start);
|
||||
endTime = TimeOnly.FromDateTime(end);
|
||||
}
|
||||
|
||||
return new AnnualPlanEvent
|
||||
{
|
||||
ExternalId = uid,
|
||||
Title = Text(properties, "SUMMARY"),
|
||||
Description = Text(properties, "DESCRIPTION"),
|
||||
Location = Text(properties, "LOCATION"),
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
IsAllDay = isAllDay,
|
||||
CalendarGroup = Text(properties, "X-GROUPNAME"),
|
||||
Color = Text(properties, "X-COLOR"),
|
||||
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
|
||||
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
|
||||
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
|
||||
};
|
||||
}
|
||||
|
||||
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
|
||||
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
|
||||
|
||||
private static string Raw(Dictionary<string, Property> properties, string key) =>
|
||||
properties.TryGetValue(key, out var value) ? value.Value : "";
|
||||
|
||||
private static string Text(Dictionary<string, Property> properties, string key) =>
|
||||
Unescape(Raw(properties, key));
|
||||
|
||||
private static DateOnly ParseDate(string value, int number, string field)
|
||||
{
|
||||
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var date)) return date;
|
||||
throw Invalid(number, $"ungültiges {field}: {value}");
|
||||
}
|
||||
|
||||
private static DateTime ParseLocalDateTime(Property property, int number, string field)
|
||||
{
|
||||
var utc = property.Value.EndsWith('Z');
|
||||
var value = utc ? property.Value[..^1] : property.Value;
|
||||
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
|
||||
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var parsed))
|
||||
throw Invalid(number, $"ungültiges {field}: {property.Value}");
|
||||
|
||||
return utc
|
||||
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
|
||||
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
private static DateTime? ParseOptionalUtc(Property property, int number)
|
||||
{
|
||||
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
|
||||
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
|
||||
return property.Value.EndsWith('Z')
|
||||
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
|
||||
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
|
||||
}
|
||||
|
||||
private static List<string> Unfold(string text)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
|
||||
{
|
||||
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
|
||||
result[^1] += line[1..];
|
||||
else
|
||||
result.Add(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Unescape(string value)
|
||||
{
|
||||
var result = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != '\\' || i + 1 >= value.Length)
|
||||
{
|
||||
result.Append(value[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = value[++i];
|
||||
result.Append(next switch
|
||||
{
|
||||
'n' or 'N' => '\n',
|
||||
'\\' => '\\',
|
||||
',' => ',',
|
||||
';' => ';',
|
||||
_ => next,
|
||||
});
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static FormatException Invalid(int number, string message) =>
|
||||
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
|
||||
}
|
||||
Reference in New Issue
Block a user