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);
}
}
}