Add calendar integrations and collaboration updates
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-05-05 15:25:53 -04:00
parent c49f03ec06
commit b66c10b681
82 changed files with 8420 additions and 2048 deletions

View File

@@ -0,0 +1,80 @@
using System.Text;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed record CalendarExportFeedEvent(
string Uid,
string Title,
DateTimeOffset StartsAt,
DateTimeOffset EndsAt,
bool IsAllDay,
string? Description,
string? Url);
public class CalendarExportFeedBuilder
{
public string Build(string calendarName, IReadOnlyCollection<CalendarExportFeedEvent> events)
{
StringBuilder builder = new();
builder.AppendLine("BEGIN:VCALENDAR");
builder.AppendLine("VERSION:2.0");
builder.AppendLine("PRODID:-//Socialize//User Work Calendar//EN");
builder.AppendLine("CALSCALE:GREGORIAN");
builder.AppendLine("METHOD:PUBLISH");
builder.AppendLine($"X-WR-CALNAME:{EscapeText(calendarName)}");
foreach (CalendarExportFeedEvent feedEvent in events.OrderBy(calendarEvent => calendarEvent.StartsAt))
{
builder.AppendLine("BEGIN:VEVENT");
builder.AppendLine($"UID:{EscapeText(feedEvent.Uid)}");
builder.AppendLine($"DTSTAMP:{FormatUtc(DateTimeOffset.UtcNow)}");
builder.AppendLine($"SUMMARY:{EscapeText(feedEvent.Title)}");
if (feedEvent.IsAllDay)
{
builder.AppendLine($"DTSTART;VALUE=DATE:{FormatDate(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND;VALUE=DATE:{FormatDate(feedEvent.EndsAt)}");
}
else
{
builder.AppendLine($"DTSTART:{FormatUtc(feedEvent.StartsAt)}");
builder.AppendLine($"DTEND:{FormatUtc(feedEvent.EndsAt)}");
}
if (!string.IsNullOrWhiteSpace(feedEvent.Description))
{
builder.AppendLine($"DESCRIPTION:{EscapeText(feedEvent.Description)}");
}
if (!string.IsNullOrWhiteSpace(feedEvent.Url))
{
builder.AppendLine($"URL:{EscapeText(feedEvent.Url)}");
}
builder.AppendLine("END:VEVENT");
}
builder.AppendLine("END:VCALENDAR");
return builder.ToString();
}
private static string FormatDate(DateTimeOffset value)
{
return value.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture);
}
private static string FormatUtc(DateTimeOffset value)
{
return value.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
private static string EscapeText(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\r\n", "\\n")
.Replace("\n", "\\n")
.Replace(";", "\\;")
.Replace(",", "\\,");
}
}

View File

@@ -0,0 +1,173 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public class CalendarExportFeedService(AppDbContext dbContext, CalendarExportFeedBuilder feedBuilder)
{
public async Task<string> BuildUserFeedAsync(Guid userId, string? userEmail, string appBaseUrl, CancellationToken ct)
{
string normalizedEmail = userEmail?.Trim().ToUpperInvariant() ?? string.Empty;
Guid[] workspaceIds = await dbContext.Workspaces
.Where(workspace =>
workspace.OwnerUserId == userId ||
dbContext.OrganizationMemberships.Any(membership =>
membership.OrganizationId == workspace.OrganizationId &&
membership.UserId == userId))
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
List<CalendarExportFeedEvent> events = [];
events.AddRange(await dbContext.ContentItems
.Where(item => workspaceIds.Contains(item.WorkspaceId) && item.DueDate.HasValue)
.Join(
dbContext.Workspaces,
item => item.WorkspaceId,
workspace => workspace.Id,
(item, workspace) => new { item, workspace })
.Join(
dbContext.Clients,
itemWorkspace => itemWorkspace.item.ClientId,
client => client.Id,
(itemWorkspace, client) => new { itemWorkspace.item, itemWorkspace.workspace, client })
.Join(
dbContext.Campaigns,
itemWorkspaceClient => itemWorkspaceClient.item.CampaignId,
campaign => campaign.Id,
(itemWorkspaceClient, campaign) => new { itemWorkspaceClient.item, itemWorkspaceClient.workspace, itemWorkspaceClient.client, campaign })
.Select(candidate => ToContentFeedEvent(
candidate.item.Id,
candidate.item.Title,
candidate.item.Status,
candidate.item.DueDate!.Value,
candidate.workspace.Name,
candidate.client.Name,
candidate.campaign.Name,
appBaseUrl))
.ToListAsync(ct));
events.AddRange(await dbContext.ApprovalRequests
.Where(approval =>
approval.DueAt.HasValue &&
(approval.RequestedByUserId == userId ||
(!string.IsNullOrEmpty(normalizedEmail) && approval.ReviewerEmail.ToUpper() == normalizedEmail)))
.Join(
dbContext.ContentItems,
approval => approval.ContentItemId,
item => item.Id,
(approval, item) => new { approval, item })
.Where(candidate => workspaceIds.Contains(candidate.approval.WorkspaceId))
.Join(
dbContext.Workspaces,
approvalItem => approvalItem.approval.WorkspaceId,
workspace => workspace.Id,
(approvalItem, workspace) => new { approvalItem.approval, approvalItem.item, workspace })
.Select(candidate => ToApprovalFeedEvent(
candidate.approval.Id,
candidate.item.Id,
candidate.item.Title,
candidate.approval.Stage,
candidate.approval.State,
candidate.approval.DueAt!.Value,
candidate.workspace.Name,
appBaseUrl))
.ToListAsync(ct));
events.AddRange(await dbContext.Campaigns
.Where(campaign => workspaceIds.Contains(campaign.WorkspaceId))
.Join(
dbContext.Workspaces,
campaign => campaign.WorkspaceId,
workspace => workspace.Id,
(campaign, workspace) => new { campaign, workspace })
.Select(candidate => ToCampaignFeedEvent(
candidate.campaign.Id,
candidate.campaign.Name,
candidate.campaign.Status,
candidate.campaign.StartDate,
candidate.campaign.EndDate,
candidate.workspace.Name,
appBaseUrl))
.ToListAsync(ct));
return feedBuilder.Build("Socialize my work", events);
}
private static CalendarExportFeedEvent ToContentFeedEvent(
Guid contentItemId,
string title,
string status,
DateTimeOffset dueDate,
string workspaceName,
string clientName,
string campaignName,
string appBaseUrl)
{
(DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueDate);
return new CalendarExportFeedEvent(
$"content-{contentItemId}@socialize",
title,
start,
end,
isAllDay,
$"Status: {status}\nWorkspace: {workspaceName}\nClient: {clientName}\nCampaign: {campaignName}",
$"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}");
}
private static CalendarExportFeedEvent ToApprovalFeedEvent(
Guid approvalId,
Guid contentItemId,
string contentTitle,
string stage,
string state,
DateTimeOffset dueAt,
string workspaceName,
string appBaseUrl)
{
(DateTimeOffset start, DateTimeOffset end, bool isAllDay) = NormalizeEventTime(dueAt);
return new CalendarExportFeedEvent(
$"approval-{approvalId}@socialize",
$"Approval due: {contentTitle}",
start,
end,
isAllDay,
$"Stage: {stage}\nState: {state}\nWorkspace: {workspaceName}",
$"{appBaseUrl.TrimEnd('/')}/app/content/{contentItemId}");
}
private static CalendarExportFeedEvent ToCampaignFeedEvent(
Guid campaignId,
string name,
string status,
DateTimeOffset startDate,
DateTimeOffset endDate,
string workspaceName,
string appBaseUrl)
{
DateTimeOffset start = new(startDate.Date, startDate.Offset);
DateTimeOffset end = new(endDate.Date.AddDays(1), endDate.Offset);
return new CalendarExportFeedEvent(
$"campaign-{campaignId}@socialize",
$"Campaign: {name}",
start,
end <= start ? start.AddDays(1) : end,
true,
$"Status: {status}\nWorkspace: {workspaceName}",
$"{appBaseUrl.TrimEnd('/')}/app/campaigns/{campaignId}");
}
private static (DateTimeOffset Start, DateTimeOffset End, bool IsAllDay) NormalizeEventTime(DateTimeOffset value)
{
if (value.TimeOfDay == TimeSpan.Zero)
{
DateTimeOffset start = new(value.Date, value.Offset);
return (start, start.AddDays(1), true);
}
return (value, value.AddMinutes(30), false);
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Cryptography;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarExportFeedTokenService
{
public static string GenerateToken()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncode(bytes);
}
public static string HashToken(string token)
{
byte[] bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -0,0 +1,34 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<CalendarImportBackgroundService> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromHours(6));
while (!stoppingToken.IsCancellationRequested)
{
await RefreshDueSourcesAsync(stoppingToken);
await timer.WaitForNextTickAsync(stoppingToken);
}
}
private async Task RefreshDueSourcesAsync(CancellationToken stoppingToken)
{
try
{
using IServiceScope scope = scopeFactory.CreateScope();
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
await syncService.RefreshDueSourcesAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogError(ex, "Calendar import background sync failed.");
}
}
}

View File

@@ -0,0 +1,313 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.CalendarIntegrations.Data;
using System.Text.Json;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public sealed class CalendarImportSyncService(
AppDbContext dbContext,
IHttpClientFactory httpClientFactory,
IcsCalendarParser parser)
{
public async Task RefreshSourceAsync(Guid sourceId, CancellationToken ct)
{
CalendarSource? source = await dbContext.CalendarSources
.SingleOrDefaultAsync(candidate => candidate.Id == sourceId, ct);
if (source is null)
{
throw new InvalidOperationException("Calendar source was not found.");
}
source.LastAttemptedSyncAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(source.SourceUrl))
{
source.LastSyncError = "Calendar source does not have a source URL.";
await dbContext.SaveChangesAsync(ct);
return;
}
try
{
using HttpClient httpClient = httpClientFactory.CreateClient();
DateOnly rangeStart = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1));
DateOnly rangeEnd = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(2));
IReadOnlyCollection<ParsedCalendarEvent> parsedEvents = await GetParsedEventsAsync(
httpClient,
source.SourceUrl,
rangeStart,
rangeEnd,
ct);
await ReplaceEventsAsync(source.Id, parsedEvents, ct);
source.LastSuccessfulSyncAt = DateTimeOffset.UtcNow;
source.LastSyncError = null;
source.LastAttemptedSyncAt = source.LastSuccessfulSyncAt;
await dbContext.SaveChangesAsync(ct);
}
catch (HttpRequestException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
catch (FormatException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
catch (InvalidOperationException ex)
{
await RecordSyncFailureAsync(source, ex.Message, ct);
}
}
public async Task RefreshDueSourcesAsync(CancellationToken ct)
{
DateTimeOffset staleBefore = DateTimeOffset.UtcNow.AddHours(-12);
Guid[] sourceIds = await dbContext.CalendarSources
.Where(source => source.IsEnabled && source.SourceUrl != null)
.Where(source => source.LastAttemptedSyncAt == null || source.LastAttemptedSyncAt < staleBefore)
.OrderBy(source => source.LastAttemptedSyncAt)
.Select(source => source.Id)
.Take(25)
.ToArrayAsync(ct);
foreach (Guid sourceId in sourceIds)
{
await RefreshSourceAsync(sourceId, ct);
}
}
private async Task ReplaceEventsAsync(
Guid sourceId,
IReadOnlyCollection<ParsedCalendarEvent> parsedEvents,
CancellationToken ct)
{
await dbContext.CalendarEvents
.Where(calendarEvent => calendarEvent.CalendarSourceId == sourceId)
.ExecuteDeleteAsync(ct);
DateTimeOffset importedAt = DateTimeOffset.UtcNow;
foreach (ParsedCalendarEvent parsedEvent in parsedEvents)
{
dbContext.CalendarEvents.Add(new CalendarEvent
{
Id = Guid.NewGuid(),
CalendarSourceId = sourceId,
SourceEventUid = parsedEvent.SourceEventUid,
Title = parsedEvent.Title,
Description = parsedEvent.Description,
IsAllDay = parsedEvent.IsAllDay,
IsFloatingTime = parsedEvent.IsFloatingTime,
StartDate = parsedEvent.StartDate,
EndDate = parsedEvent.EndDate,
StartLocalDateTime = parsedEvent.StartLocalDateTime,
EndLocalDateTime = parsedEvent.EndLocalDateTime,
StartUtc = parsedEvent.StartUtc,
EndUtc = parsedEvent.EndUtc,
TimeZoneId = parsedEvent.TimeZoneId,
RecurrenceId = parsedEvent.RecurrenceId,
Location = parsedEvent.Location,
SourceUrl = parsedEvent.SourceUrl,
SourceLastModifiedAt = parsedEvent.SourceLastModifiedAt,
ImportedAt = importedAt,
});
}
}
private async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetParsedEventsAsync(
HttpClient httpClient,
string sourceUrl,
DateOnly rangeStart,
DateOnly rangeEnd,
CancellationToken ct)
{
if (TryGetNagerCountryCode(sourceUrl, out string? countryCode))
{
return await GetNagerEventsAsync(httpClient, sourceUrl, countryCode!, rangeStart, rangeEnd, ct);
}
string content = await httpClient.GetStringAsync(sourceUrl, ct);
return parser.Parse(content, rangeStart, rangeEnd);
}
private static async Task<IReadOnlyCollection<ParsedCalendarEvent>> GetNagerEventsAsync(
HttpClient httpClient,
string sourceUrl,
string countryCode,
DateOnly rangeStart,
DateOnly rangeEnd,
CancellationToken ct)
{
List<ParsedCalendarEvent> events = [];
for (int year = rangeStart.Year; year <= rangeEnd.Year; year++)
{
string yearUrl = BuildNagerYearUrl(sourceUrl, countryCode, year);
string json = await httpClient.GetStringAsync(yearUrl, ct);
NagerHoliday[] holidays = JsonSerializer.Deserialize<NagerHoliday[]>(
json,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
foreach (NagerHoliday holiday in holidays)
{
if (!DateOnly.TryParse(holiday.Date, out DateOnly date) ||
date < rangeStart ||
date > rangeEnd)
{
continue;
}
events.Add(ToParsedEvent(
$"nager-{countryCode}-{date:yyyyMMdd}-{NormalizeUidPart(holiday.Name)}",
string.IsNullOrWhiteSpace(holiday.Name) ? holiday.LocalName : holiday.Name,
holiday.LocalName,
date,
string.Join(", ", holiday.Types ?? []),
yearUrl));
}
events.AddRange(GetSupplementalCountryEvents(countryCode, year, rangeStart, rangeEnd));
}
return events
.GroupBy(calendarEvent => calendarEvent.SourceEventUid)
.Select(group => group.First())
.OrderBy(calendarEvent => calendarEvent.StartDate)
.ThenBy(calendarEvent => calendarEvent.Title)
.ToArray();
}
private static ParsedCalendarEvent ToParsedEvent(
string uid,
string? title,
string? localName,
DateOnly date,
string? types,
string sourceUrl)
{
string? description = string.IsNullOrWhiteSpace(types)
? localName
: $"{localName}\nTypes: {types}";
return new ParsedCalendarEvent(
uid,
string.IsNullOrWhiteSpace(title) ? "Untitled event" : title,
description,
IsAllDay: true,
IsFloatingTime: false,
date,
date.AddDays(1),
StartLocalDateTime: null,
EndLocalDateTime: null,
StartUtc: null,
EndUtc: null,
TimeZoneId: null,
RecurrenceId: null,
Location: null,
sourceUrl,
SourceLastModifiedAt: null);
}
private static IReadOnlyCollection<ParsedCalendarEvent> GetSupplementalCountryEvents(
string countryCode,
int year,
DateOnly rangeStart,
DateOnly rangeEnd)
{
if (!countryCode.Equals("CA", StringComparison.OrdinalIgnoreCase))
{
return [];
}
DateOnly mothersDay = NthWeekdayOfMonth(year, month: 5, DayOfWeek.Sunday, occurrence: 2);
if (mothersDay < rangeStart || mothersDay > rangeEnd)
{
return [];
}
return
[
ToParsedEvent(
$"socialize-ca-mothers-day-{year}",
"Mother's Day",
"Mother's Day",
mothersDay,
"Observance",
"socialize://calendar-observances/CA"),
];
}
private static DateOnly NthWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek, int occurrence)
{
DateOnly date = new(year, month, 1);
while (date.DayOfWeek != dayOfWeek)
{
date = date.AddDays(1);
}
return date.AddDays((occurrence - 1) * 7);
}
private static bool TryGetNagerCountryCode(string sourceUrl, out string? countryCode)
{
countryCode = null;
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri) ||
!uri.Host.Contains("date.nager.at", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string[] segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string? candidate = segments.LastOrDefault(segment => segment.Length == 2);
if (candidate is null)
{
return false;
}
countryCode = candidate.ToUpperInvariant();
return true;
}
private static string BuildNagerYearUrl(string sourceUrl, string countryCode, int year)
{
if (Uri.TryCreate(sourceUrl, UriKind.Absolute, out Uri? uri))
{
return $"{uri.Scheme}://{uri.Host}/api/v3/PublicHolidays/{year}/{countryCode}";
}
return $"https://date.nager.at/api/v3/PublicHolidays/{year}/{countryCode}";
}
private static string NormalizeUidPart(string? value)
{
return new string((value ?? "holiday")
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray())
.Trim('-');
}
private async Task RecordSyncFailureAsync(
CalendarSource source,
string message,
CancellationToken ct)
{
source.LastSyncError = NormalizeSyncError(message);
await dbContext.SaveChangesAsync(ct);
}
public static string NormalizeSyncError(string message)
{
ArgumentNullException.ThrowIfNull(message);
return message.Length > 2048 ? message[..2048] : message;
}
private sealed record NagerHoliday(
string Date,
string LocalName,
string Name,
string[]? Types);
}

View File

@@ -0,0 +1,14 @@
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarSourceScopes
{
public const string Organization = "Organization";
public const string Workspace = "Workspace";
public const string User = "User";
}
public static class CalendarSourceInheritanceModes
{
public const string Required = "Required";
public const string Optional = "Optional";
}

View File

@@ -0,0 +1,51 @@
using Socialize.Api.Modules.CalendarIntegrations.Data;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public static class CalendarSourceRules
{
public static readonly string[] SupportedScopes =
[
CalendarSourceScopes.Organization,
CalendarSourceScopes.Workspace,
CalendarSourceScopes.User,
];
public static readonly string[] SupportedInheritanceModes =
[
CalendarSourceInheritanceModes.Required,
CalendarSourceInheritanceModes.Optional,
];
public static bool IsSupportedScope(string? scope)
{
return SupportedScopes.Contains(scope?.Trim(), StringComparer.Ordinal);
}
public static bool IsSupportedInheritanceMode(string? inheritanceMode)
{
return SupportedInheritanceModes.Contains(inheritanceMode?.Trim(), StringComparer.Ordinal);
}
public static bool IsInheritedOrganizationSource(CalendarSource source, Guid workspaceOrganizationId)
{
return source.Scope == CalendarSourceScopes.Organization &&
source.OrganizationId == workspaceOrganizationId;
}
public static bool CanManageScope(
string scope,
bool canManageOrganizationCalendars,
bool canManageWorkspaceCalendars,
Guid currentUserId,
Guid? sourceUserId)
{
return scope switch
{
CalendarSourceScopes.Organization => canManageOrganizationCalendars,
CalendarSourceScopes.Workspace => canManageWorkspaceCalendars,
CalendarSourceScopes.User => sourceUserId == currentUserId,
_ => false,
};
}
}

View File

@@ -0,0 +1,414 @@
using System.Globalization;
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
public record ParsedCalendarEvent(
string SourceEventUid,
string Title,
string? Description,
bool IsAllDay,
bool IsFloatingTime,
DateOnly StartDate,
DateOnly EndDate,
DateTime? StartLocalDateTime,
DateTime? EndLocalDateTime,
DateTimeOffset? StartUtc,
DateTimeOffset? EndUtc,
string? TimeZoneId,
string? RecurrenceId,
string? Location,
string? SourceUrl,
DateTimeOffset? SourceLastModifiedAt);
internal record IcsDateTimeValue(
bool IsAllDay,
bool IsFloatingTime,
DateOnly Date,
DateTime? LocalDateTime,
DateTimeOffset? UtcDateTime,
string? TimeZoneId);
internal sealed record IcsRawEvent(
string Uid,
string Title,
string? Description,
IcsDateTimeValue Start,
IcsDateTimeValue? End,
string? RRule,
string? Location,
string? SourceUrl,
DateTimeOffset? LastModifiedAt);
public sealed class IcsCalendarParser
{
public IReadOnlyCollection<ParsedCalendarEvent> Parse(
string content,
DateOnly rangeStart,
DateOnly rangeEnd)
{
ArgumentNullException.ThrowIfNull(content);
List<ParsedCalendarEvent> events = [];
foreach (IcsRawEvent rawEvent in ReadRawEvents(content))
{
events.AddRange(Expand(rawEvent, rangeStart, rangeEnd));
}
return events
.OrderBy(calendarEvent => calendarEvent.StartDate)
.ThenBy(calendarEvent => calendarEvent.Title)
.ToArray();
}
private static IEnumerable<IcsRawEvent> ReadRawEvents(string content)
{
List<string> lines = UnfoldLines(content).ToList();
for (int index = 0; index < lines.Count; index++)
{
if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
continue;
}
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties =
new(StringComparer.OrdinalIgnoreCase);
index++;
for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++)
{
ParseProperty(lines[index], properties);
}
if (!TryGetFirst(properties, "DTSTART", out var startProperty))
{
continue;
}
IcsDateTimeValue start = ParseDateTimeValue(startProperty.Value, startProperty.Parameters);
IcsDateTimeValue? end = TryGetFirst(properties, "DTEND", out var endProperty)
? ParseDateTimeValue(endProperty.Value, endProperty.Parameters)
: null;
string uid = TryGetFirst(properties, "UID", out var uidProperty)
? uidProperty.Value
: $"{start.Date:yyyyMMdd}:{GetText(properties, "SUMMARY") ?? "calendar-event"}";
yield return new IcsRawEvent(
uid,
GetText(properties, "SUMMARY") ?? "Untitled event",
GetText(properties, "DESCRIPTION"),
start,
end,
GetText(properties, "RRULE"),
GetText(properties, "LOCATION"),
GetText(properties, "URL"),
TryGetFirst(properties, "LAST-MODIFIED", out var lastModified)
? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime
: null);
}
}
private static IEnumerable<string> UnfoldLines(string content)
{
string? current = null;
using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'));
while (reader.ReadLine() is { } line)
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null)
{
current += line[1..];
continue;
}
if (current is not null)
{
yield return current;
}
current = line;
}
if (current is not null)
{
yield return current;
}
}
private static void ParseProperty(
string line,
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties)
{
int separatorIndex = line.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex < 0)
{
return;
}
string nameAndParameters = line[..separatorIndex];
string value = UnescapeText(line[(separatorIndex + 1)..]);
string[] nameParts = nameAndParameters.Split(';');
string name = nameParts[0];
Dictionary<string, string> parameters = new(StringComparer.OrdinalIgnoreCase);
foreach (string parameterPart in nameParts.Skip(1))
{
int equalsIndex = parameterPart.IndexOf('=', StringComparison.Ordinal);
if (equalsIndex > 0)
{
parameters[parameterPart[..equalsIndex]] = parameterPart[(equalsIndex + 1)..].Trim('"');
}
}
if (!properties.TryGetValue(name, out var values))
{
values = [];
properties[name] = values;
}
values.Add((parameters, value));
}
private static string UnescapeText(string value)
{
return value
.Replace("\\n", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("\\,", ",", StringComparison.Ordinal)
.Replace("\\;", ";", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal);
}
private static bool TryGetFirst(
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key,
out (Dictionary<string, string> Parameters, string Value) value)
{
if (properties.TryGetValue(key, out var values) && values.Count > 0)
{
value = values[0];
return true;
}
value = default;
return false;
}
private static string? GetText(
Dictionary<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key)
{
return TryGetFirst(properties, key, out var value) ? value.Value : null;
}
private static IcsDateTimeValue ParseDateTimeValue(
string value,
Dictionary<string, string> parameters)
{
bool isAllDay = parameters.TryGetValue("VALUE", out string? valueType) &&
valueType.Equals("DATE", StringComparison.OrdinalIgnoreCase);
if (isAllDay || value.Length == 8)
{
DateOnly date = DateOnly.ParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture);
return new IcsDateTimeValue(true, false, date, null, null, null);
}
bool utc = value.EndsWith('Z');
string parseValue = utc ? value[..^1] : value;
DateTime local = DateTime.ParseExact(parseValue, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
if (utc)
{
DateTimeOffset utcValue = new(DateTime.SpecifyKind(local, DateTimeKind.Utc));
return new IcsDateTimeValue(false, false, DateOnly.FromDateTime(local), local, utcValue, "UTC");
}
string? timeZoneId = parameters.GetValueOrDefault("TZID");
bool floating = string.IsNullOrWhiteSpace(timeZoneId);
return new IcsDateTimeValue(
false,
floating,
DateOnly.FromDateTime(local),
local,
TryConvertToUtc(local, timeZoneId),
timeZoneId);
}
private static DateTimeOffset? TryConvertToUtc(DateTime localDateTime, string? timeZoneId)
{
if (string.IsNullOrWhiteSpace(timeZoneId))
{
return null;
}
try
{
TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified);
return TimeZoneInfo.ConvertTimeToUtc(unspecified, timeZone);
}
catch (TimeZoneNotFoundException)
{
return null;
}
catch (InvalidTimeZoneException)
{
return null;
}
}
private static IEnumerable<ParsedCalendarEvent> Expand(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End);
IReadOnlyCollection<DateOnly> starts = ExpandStartDates(rawEvent, rangeStart, rangeEnd);
foreach (DateOnly startDate in starts)
{
int dayOffset = startDate.DayNumber - rawEvent.Start.Date.DayNumber;
IcsDateTimeValue occurrenceStart = Shift(rawEvent.Start, dayOffset);
IcsDateTimeValue occurrenceEnd = rawEvent.End is null
? ShiftByDuration(occurrenceStart, duration)
: Shift(rawEvent.End, dayOffset);
yield return new ParsedCalendarEvent(
rawEvent.Uid,
rawEvent.Title,
rawEvent.Description,
occurrenceStart.IsAllDay,
occurrenceStart.IsFloatingTime,
occurrenceStart.Date,
occurrenceEnd.Date,
occurrenceStart.LocalDateTime,
occurrenceEnd.LocalDateTime,
occurrenceStart.UtcDateTime,
occurrenceEnd.UtcDateTime,
occurrenceStart.TimeZoneId,
rawEvent.RRule is null ? null : rawEvent.Uid,
rawEvent.Location,
rawEvent.SourceUrl,
rawEvent.LastModifiedAt);
}
}
private static TimeSpan GetDuration(IcsDateTimeValue start, IcsDateTimeValue? end)
{
if (end is null)
{
return start.IsAllDay ? TimeSpan.FromDays(1) : TimeSpan.Zero;
}
if (start.IsAllDay)
{
return TimeSpan.FromDays(Math.Max(1, end.Date.DayNumber - start.Date.DayNumber));
}
if (start.LocalDateTime.HasValue && end.LocalDateTime.HasValue)
{
return end.LocalDateTime.Value - start.LocalDateTime.Value;
}
return TimeSpan.Zero;
}
private static IReadOnlyCollection<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
if (string.IsNullOrWhiteSpace(rawEvent.RRule))
{
return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : [];
}
Dictionary<string, string> rule = rawEvent.RRule
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => part.Split('=', 2))
.Where(parts => parts.Length == 2)
.ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase);
string frequency = rule.GetValueOrDefault("FREQ", "DAILY");
int interval = int.TryParse(rule.GetValueOrDefault("INTERVAL"), out int parsedInterval)
? Math.Max(1, parsedInterval)
: 1;
int? count = int.TryParse(rule.GetValueOrDefault("COUNT"), out int parsedCount) ? parsedCount : null;
DateOnly? until = TryParseUntil(rule.GetValueOrDefault("UNTIL"));
List<DateOnly> dates = [];
DateOnly current = rawEvent.Start.Date;
for (int occurrence = 1; occurrence <= (count ?? 500); occurrence++)
{
if (until.HasValue && current > until.Value)
{
break;
}
if (current > rangeEnd)
{
break;
}
if (IsInRange(current, rangeStart, rangeEnd))
{
dates.Add(current);
}
current = frequency.ToUpperInvariant() switch
{
"YEARLY" => current.AddYears(interval),
"MONTHLY" => current.AddMonths(interval),
"WEEKLY" => current.AddDays(7 * interval),
_ => current.AddDays(interval),
};
}
return dates;
}
private static DateOnly? TryParseUntil(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string dateValue = value.EndsWith('Z') ? value[..^1] : value;
if (dateValue.Length >= 8 &&
DateOnly.TryParseExact(dateValue[..8], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date))
{
return date;
}
return null;
}
private static bool IsInRange(DateOnly value, DateOnly rangeStart, DateOnly rangeEnd)
{
return value >= rangeStart && value <= rangeEnd;
}
private static IcsDateTimeValue Shift(IcsDateTimeValue value, int dayOffset)
{
return value with
{
Date = value.Date.AddDays(dayOffset),
LocalDateTime = value.LocalDateTime?.AddDays(dayOffset),
UtcDateTime = value.UtcDateTime?.AddDays(dayOffset),
};
}
private static IcsDateTimeValue ShiftByDuration(IcsDateTimeValue value, TimeSpan duration)
{
if (value.IsAllDay)
{
return value with { Date = value.Date.AddDays(Math.Max(1, (int)duration.TotalDays)) };
}
return value with
{
Date = value.LocalDateTime.HasValue
? DateOnly.FromDateTime(value.LocalDateTime.Value.Add(duration))
: value.Date,
LocalDateTime = value.LocalDateTime?.Add(duration),
UtcDateTime = value.UtcDateTime?.Add(duration),
};
}
}