113 lines
4.0 KiB
C#
113 lines
4.0 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Elternbeirat.Contracts;
|
|
|
|
namespace Elternbeirat.Web.Features.Events;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class IcsCalendar
|
|
{
|
|
private const string ProductId = "-//Elternbeirat IGMH//Termine//DE";
|
|
|
|
/// <summary>
|
|
/// Serialises the given entries into a single VCALENDAR document.
|
|
/// </summary>
|
|
public static string Build(IReadOnlyList<Event> 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 (HasTime(ev))
|
|
{
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// An entry counts as all-day when its start has no wall-clock time (00:00).
|
|
/// PocketBase has no separate flag; the convention is a midnight start.
|
|
/// </summary>
|
|
private static bool HasTime(Event ev) => ev.Start.TimeOfDay != TimeSpan.Zero;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
|
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");
|
|
}
|