using System.Globalization; using System.Security.Cryptography; using System.Text; using Elternbeirat.Web.Services; namespace Elternbeirat.Web.Features.Events; /// /// Builds an iCalendar (RFC 5545) document from the events so visitors can /// subscribe to the calendar in their own app. Times are written as local /// wall-clock times without a time zone, which is what a school calendar needs: /// "19:30" should read as 19:30 everywhere. /// public static class IcsCalendar { private const string ProductId = "-//Elternbeirat IGMH//Termine//DE"; /// /// Serialises the given entries into a single VCALENDAR document. /// public static string Build(IReadOnlyList events) { var sb = new StringBuilder(); AppendLine(sb, "BEGIN:VCALENDAR"); AppendLine(sb, "VERSION:2.0"); AppendLine(sb, $"PRODID:{ProductId}"); AppendLine(sb, "CALSCALE:GREGORIAN"); AppendLine(sb, "METHOD:PUBLISH"); AppendLine(sb, "X-WR-CALNAME:Elternbeirat IGMH"); foreach (var ev in events) { AppendEvent(sb, ev); } AppendLine(sb, "END:VCALENDAR"); return sb.ToString(); } private static void AppendEvent(StringBuilder sb, Event ev) { AppendLine(sb, "BEGIN:VEVENT"); AppendLine(sb, $"UID:{Uid(ev)}"); // No live timestamp: a stable DTSTAMP keeps the feed byte-identical // between requests, so caches and clients do not see spurious changes. AppendLine(sb, "DTSTAMP:20000101T000000Z"); if (ev.HasTime) { AppendLine(sb, $"DTSTART:{Local(ev.Start)}"); if (ev.End is { } end) { AppendLine(sb, $"DTEND:{Local(end)}"); } } else { AppendLine(sb, $"DTSTART;VALUE=DATE:{Date(ev.Start)}"); // For an all-day event DTEND is exclusive: add one day so a single // day shows as one day, and a range covers its last day. var end = ev.End ?? ev.Start; AppendLine(sb, $"DTEND;VALUE=DATE:{Date(end.AddDays(1))}"); } AppendLine(sb, $"SUMMARY:{Escape(ev.Title)}"); if (!string.IsNullOrWhiteSpace(ev.Location)) { AppendLine(sb, $"LOCATION:{Escape(ev.Location)}"); } if (!string.IsNullOrWhiteSpace(ev.Note)) { AppendLine(sb, $"DESCRIPTION:{Escape(ev.Note)}"); } AppendLine(sb, "END:VEVENT"); } /// /// A UID that stays the same as long as the entry's title and start do, so a /// re-subscribe updates the event instead of creating a duplicate. /// private static string Uid(Event ev) { var seed = $"{ev.Title}|{ev.Start:O}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); return $"{Convert.ToHexString(hash)[..16]}@elternbeirat-igmh"; } private static string Local(DateTime value) => value.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); private static string Date(DateTime value) => value.ToString("yyyyMMdd", CultureInfo.InvariantCulture); /// Escapes the characters that are special in an ICS text value. private static string Escape(string value) => value .Replace("\\", "\\\\", StringComparison.Ordinal) .Replace(";", "\\;", StringComparison.Ordinal) .Replace(",", "\\,", StringComparison.Ordinal) .Replace("\r\n", "\\n", StringComparison.Ordinal) .Replace("\n", "\\n", StringComparison.Ordinal); // ICS lines are terminated with CRLF regardless of platform. private static void AppendLine(StringBuilder sb, string line) => sb.Append(line).Append("\r\n"); }