314 lines
10 KiB
C#
314 lines
10 KiB
C#
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);
|
|
}
|