using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Net.Http; using System.Text; using System.Threading.Tasks; using System.Xml; namespace BlazorApp.Data { public class Event { public string Uid { get; set; } public string Summary { get; set; } public DateTime? DtStart { get; set; } public DateTime? DtEnd { get; set; } public string Description { get; set; } public TimeSpan? Duration { get; set; } public DateTime? CalculatedEnd => DtEnd ?? DtStart + Duration; } public class Calendar { 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') ? "yyyyMMddTHHmmssK" : "yyyyMMdd"; 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; } switch (c) { case 'W': timeSpan += TimeSpan.FromDays(value * 7); break; case 'D': timeSpan += TimeSpan.FromDays(value); break; case 'H': timeSpan += TimeSpan.FromHours(value); break; case 'M': timeSpan += TimeSpan.FromMinutes(value); break; case 'S': timeSpan += TimeSpan.FromSeconds(value); break; } 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; } switch (name) { case "END": if (value == "VEVENT") { // End of the current event, add it to the list events.Add(@event); @event = null; } break; 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; } /** * 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 body = await client.GetStringAsync(Url); var events = ParseICal(body); 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 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); 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(); var reader = new XmlTextReader(body); var document = new XmlDocument(); document.Load(reader); var node = document.GetElementsByTagName("C:calendar-data")[0]?.InnerText; var events = ParseICal(node); return events.Count > 0 ? events[0] : null; } } }