206 lines
8.5 KiB
C#
206 lines
8.5 KiB
C#
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}.");
|
|
}
|