Add calendar integrations and collaboration updates
This commit is contained in:
@@ -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(",", "\\,");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user