Add calendar integrations and collaboration updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user