using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using System.Xml; namespace BlazorApp.Data { public class Calendar { /// /// Format string used for parsing iCal date-time strings. /// private const string DateTimeParseFormat = "yyyyMMddTHHmmssK"; /// /// Format string used for formatting (but not parsing) iCal date-time strings. /// public const string DateTimeFormat = "yyyyMMddTHHmmss'Z'"; /// /// Format string used for parsing and formatting iCal date strings. /// private const string DateFormat = "yyyyMMdd"; public int Id { get; set; } [Required] public string Url { get; set; } public string Username { get; set; } public string Password { get; set; } [Required] public string Name { get; set; } private string AuthorizationHeader => Username == null || Password == null ? null : $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))}"; private static DateTime _parseDateTime(string s) { var format = s.Contains('T') ? DateTimeParseFormat : DateFormat; return DateTime.ParseExact(s, format, CultureInfo.InvariantCulture); } /// /// Very non-strict parser of durations as specified in /// https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.6 /// public static TimeSpan? ParseDuration(string s) { var timeSpan = new TimeSpan(); var positive = s[0] != '-'; var value = 0; foreach (var c in s) { if (char.IsDigit(c)) { value = value * 10 + (c - '0'); continue; } timeSpan += c switch { 'W' => TimeSpan.FromDays(value * 7), 'D' => TimeSpan.FromDays(value), 'H' => TimeSpan.FromHours(value), 'M' => TimeSpan.FromMinutes(value), 'S' => TimeSpan.FromSeconds(value), _ => throw new ArgumentOutOfRangeException($"Invalid time unit {c}") }; value = 0; } return positive ? timeSpan : -timeSpan; } private static List ParseICal(string iCal) { Event @event = null; var events = new List(); // Un-wrap all lines from the input var body = iCal .Replace("\n ", "") .Replace("\n\t", "") .Replace("\r", ""); foreach (var line in body.Split("\n")) { var trimmed = line.Trim(); var split = trimmed.Split(':', 2); if (split.Length != 2) continue; var left = split[0].Trim().Split(';'); var name = left[0]; // TODO Turn this into a regex var value = split[1] .Replace(@"\n", "\n") .Replace(@"\N", "\n") .Replace(@"\,", ",") .Replace(@"\;", ";") .Replace(@"\\", @"\"); if (name == "BEGIN" && value == "VEVENT") { // Start of a new event @event = new Event(); continue; } if (@event == null) { // Event hasn't started yet, ignore the property. continue; } if (name == "END" && value == "VEVENT") { // End of the current event, add it to the list events.Add(@event); @event = null; continue; } var parameters = left.Length == 2 ? left[1] : null; // Add property to the RawProperties list. // Used for rebuilding the full iCal for updating the event on the server. @event.RawProperties.Add((name, parameters, value)); switch (name) { case "UID": @event.Uid = value; break; case "SUMMARY": @event.Summary = value; break; case "DESCRIPTION": @event.Description = value; break; case "DTSTART": @event.DtStart = _parseDateTime(value); break; case "DTEND": @event.DtEnd = _parseDateTime(value); break; case "DURATION": @event.Duration = ParseDuration(value); break; } } return events; } private static List _filterSortEvents(List events) { var now = DateTime.Now; events = events.FindAll(@event => @event.DtStart > now || @event.DtEnd > now); events.Sort((x, y) => { if (x.DtStart == null) return -1; if (y.DtStart == null) return 1; return DateTime.Compare(x.DtStart.Value, y.DtStart.Value); }); return events; } /** * Retrieves and parses all events from the CalDav server. */ public async Task> GetEvents() { var client = new HttpClient(); if (AuthorizationHeader != null) client.DefaultRequestHeaders.Add("Authorization", AuthorizationHeader); var response = await client.GetAsync(Url); var eTag = response.Headers.ETag?.Tag; var body = await response.Content.ReadAsStringAsync(); var events = ParseICal(body); foreach (var @event in events) @event.ETag = eTag; return _filterSortEvents(events); } /** * Retrieves and parses an event with UID uid from the CalDav server. */ public async Task GetEventByUid(string uid) { var client = new HttpClient(); if (AuthorizationHeader != null) client.DefaultRequestHeaders.Add("Authorization", AuthorizationHeader); // Request the event with UID `uid`. var request = new HttpRequestMessage(new HttpMethod("REPORT"), Url); request.Headers.Add("Depth", "1"); // TODO Use XML generator to safely add UID - this is sensitive to scary injection stuff request.Content = new StringContent(@$" {uid} ", Encoding.UTF8, "application/xml"); var result = await client.SendAsync(request); var body = await result.Content.ReadAsStreamAsync(); // Parse the received XML var reader = new XmlTextReader(body); var document = new XmlDocument(); document.Load(reader); // Extract and parse the ICal data var dataNode = document.GetElementsByTagName("C:calendar-data")[0]?.InnerText; if (dataNode == null) return null; var events = ParseICal(dataNode); if (events.Count == 0) return null; var @event = events[0]; // Extract the ETag and event collection href. @event.ETag = document.GetElementsByTagName("getetag")[0]?.InnerText; @event.Href = document.GetElementsByTagName("href")[0]?.InnerText; return @event; } public async Task UpdateEvent(Event @event) { var client = new HttpClient(); if (AuthorizationHeader != null) client.DefaultRequestHeaders.Add("Authorization", AuthorizationHeader); // Setting If-Match header to the ETag is required when updating an event. if (@event.ETag != null) client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue(@event.ETag)); // Build a new URL based on the calendar's URL and the event's collection href. var url = new Uri(new Uri(Url), @event.Href); var iCal = @event.ToICal(); var content = new StringContent(iCal, Encoding.UTF8, "text/calendar"); var response = await client.PutAsync(url, content); // Parse the newly updated event. var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { Console.WriteLine($"Could not update event: {body}"); return @event; } var events = ParseICal(body); if (events.Count == 0) return @event; var newEvent = events[0]; // Extract the updated ETag newEvent.ETag = response.Headers.ETag?.Tag; return newEvent; } public async Task DeleteEvent(Event @event) { // Only delete when we have an E-Tag if (@event.ETag == null) { Console.WriteLine($"Can not delete event '{@event.Uid}' because it has no ETag"); return; } // TODO Can probably make `client` a member variable var client = new HttpClient(); if (AuthorizationHeader != null) client.DefaultRequestHeaders.Add("Authorization", AuthorizationHeader); // Build a new URL based on the calendar's URL and the event's collection href. var url = new Uri(new Uri(Url), @event.Href); await client.DeleteAsync(url); } } }