Add events calendar with ICS feed and switch content naming to English
This commit is contained in:
1 parent
f24a4e73a2
commit
9459d2aeaa
28 files changed
+607
-29
No files matched your search
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <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 (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");
|
||||
}
|
||||
|
||||
/// <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].ToLowerInvariant()}@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("\\", "\\\\")
|
||||
.Replace(";", "\\;")
|
||||
.Replace(",", "\\,")
|
||||
.Replace("\r\n", "\\n")
|
||||
.Replace("\n", "\\n");
|
||||
|
||||
// ICS lines are terminated with CRLF regardless of platform.
|
||||
private static void AppendLine(StringBuilder sb, string line) =>
|
||||
sb.Append(line).Append("\r\n");
|
||||
}
|
||||
Reference in new issue
Block a user