Files
Wino-Mail/Wino.Services/CalendarService.cs
T
2026-02-27 21:57:41 +01:00

463 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Itenso.TimePeriod;
using Serilog;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Messaging.Client.Calendar;
namespace Wino.Services;
public class CalendarService : BaseDatabaseService, ICalendarService
{
// Predefined reminder options in minutes
private static readonly int[] PredefinedReminderMinutes = [60, 30, 15, 5, 1];
public CalendarService(IDatabaseService databaseService) : base(databaseService)
{
}
public int[] GetPredefinedReminderMinutes() => PredefinedReminderMinutes;
/// <summary>
/// Loads the AssignedCalendar (and its MailAccount) for a CalendarItem if not already loaded.
/// </summary>
private async Task LoadAssignedCalendarAsync(CalendarItem calendarItem)
{
if (calendarItem == null || calendarItem.AssignedCalendar != null) return;
calendarItem.AssignedCalendar = await Connection.GetAsync<AccountCalendar>(calendarItem.CalendarId);
if (calendarItem.AssignedCalendar != null)
{
calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync<MailAccount>(calendarItem.AssignedCalendar.AccountId);
}
}
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
=> Connection.Table<AccountCalendar>().Where(x => x.AccountId == accountId).OrderByDescending(a => a.IsPrimary).ToListAsync();
public async Task InsertAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.InsertAsync(accountCalendar, typeof(AccountCalendar));
WeakReferenceMessenger.Default.Send(new CalendarListAdded(accountCalendar));
}
public async Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.UpdateAsync(accountCalendar, typeof(AccountCalendar));
WeakReferenceMessenger.Default.Send(new CalendarListUpdated(accountCalendar));
}
public async Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.ExecuteAsync(
"DELETE FROM CalendarItem WHERE CalendarId = ? AND AccountId = ?",
accountCalendar.Id, accountCalendar.AccountId);
await Connection.DeleteAsync<AccountCalendar>(accountCalendar.Id);
WeakReferenceMessenger.Default.Send(new CalendarListDeleted(accountCalendar));
}
public async Task DeleteCalendarItemAsync(Guid calendarItemId)
{
var calendarItem = await Connection.GetAsync<CalendarItem>(calendarItemId);
if (calendarItem == null) return;
List<CalendarItem> eventsToRemove = new() { calendarItem };
// In case of parent event, delete all child events as well.
if (!string.IsNullOrEmpty(calendarItem.Recurrence))
{
var recurringEvents = await Connection.Table<CalendarItem>().Where(a => a.RecurringCalendarItemId == calendarItemId).ToListAsync().ConfigureAwait(false);
eventsToRemove.AddRange(recurringEvents);
}
foreach (var @event in eventsToRemove)
{
await Connection.Table<CalendarItem>().DeleteAsync(x => x.Id == @event.Id).ConfigureAwait(false);
await Connection.Table<CalendarEventAttendee>().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false);
await Connection.Table<Reminder>().DeleteAsync(r => r.CalendarItemId == @event.Id).ConfigureAwait(false);
await Connection.Table<CalendarAttachment>().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false);
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(@event));
}
}
public async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
{
var calendarItem = await Connection.FindWithQueryAsync<CalendarItem>(
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?",
calendarId, calendarRemoteEventId);
if (calendarItem == null) return;
await DeleteCalendarItemAsync(calendarItem.Id);
}
public async Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees)
{
try
{
await Connection.RunInTransactionAsync((conn) =>
{
conn.Insert(calendarItem, typeof(CalendarItem));
if (attendees != null)
{
conn.InsertAll(attendees, typeof(CalendarEventAttendee));
}
});
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem));
}
catch (Exception ex)
{
Log.Error(ex, "Error creating new calendar item");
}
}
public async Task UpdateCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees)
{
try
{
await Connection.RunInTransactionAsync((conn) =>
{
conn.Update(calendarItem, typeof(CalendarItem));
// Clear existing attendees and add new ones
conn.Table<CalendarEventAttendee>().Delete(a => a.CalendarItemId == calendarItem.Id);
if (attendees != null)
{
conn.InsertAll(attendees, typeof(CalendarEventAttendee));
}
});
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(calendarItem, CalendarItemUpdateSource.Server));
}
catch (Exception ex)
{
Log.Error(ex, "Error updating calendar item");
}
}
/// <summary>
/// Retrieves calendar events for a given calendar within the specified time period.
/// Returns all events (single instances and recurring event occurrences) that overlap with the period.
/// Note: Recurring events are expected to be synced as individual instances from the server.
/// Series master events (parents) are filtered out as they should not be displayed directly.
/// </summary>
/// <param name="calendar">The calendar to retrieve events from.</param>
/// <param name="period">The time period to query events for.</param>
/// <returns>List of calendar items that fall within the requested period.</returns>
public async Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period)
{
// Fetch all non-hidden events for this calendar
var accountEvents = await Connection.Table<CalendarItem>()
.Where(x => x.CalendarId == calendar.Id && !x.IsHidden)
.ToListAsync();
var result = new List<CalendarItem>();
foreach (var calendarItem in accountEvents)
{
// Skip series master events - they should not be displayed directly.
// Individual instances are synced from the server and displayed instead.
if (calendarItem.IsRecurringParent)
continue;
calendarItem.AssignedCalendar = calendar;
// Check if the event overlaps with the requested period
if (calendarItem.Period.OverlapsWith(period))
{
result.Add(calendarItem);
}
}
return result;
}
public async Task<AccountCalendar> GetAccountCalendarAsync(Guid accountCalendarId)
{
var calendar = await Connection.GetAsync<AccountCalendar>(accountCalendarId);
if (calendar != null)
{
calendar.MailAccount = await Connection.GetAsync<MailAccount>(calendar.AccountId);
}
return calendar;
}
public async Task<CalendarItem> GetCalendarItemAsync(Guid id)
{
var calendarItem = await Connection.FindWithQueryAsync<CalendarItem>(
"SELECT * FROM CalendarItem WHERE Id = ?",
id);
await LoadAssignedCalendarAsync(calendarItem);
return calendarItem;
}
public async Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId)
{
var calendarItem = await Connection.FindWithQueryAsync<CalendarItem>(
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?",
accountCalendarId, remoteEventId);
await LoadAssignedCalendarAsync(calendarItem);
return calendarItem;
}
public Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken)
{
return Connection.ExecuteAsync(
"UPDATE AccountCalendar SET SynchronizationDeltaToken = ? WHERE Id = ?",
deltaToken, calendarId);
}
/// <summary>
/// Gets attendees for a calendar item.
/// </summary>
public Task<List<CalendarEventAttendee>> GetAttendeesAsync(Guid calendarEventTrackingId)
=> Connection.Table<CalendarEventAttendee>().Where(x => x.CalendarItemId == calendarEventTrackingId).ToListAsync();
public async Task<List<CalendarEventAttendee>> ManageEventAttendeesAsync(Guid calendarItemId, List<CalendarEventAttendee> allAttendees)
{
await Connection.RunInTransactionAsync((connection) =>
{
// Clear all attendees.
connection.Execute(
"DELETE FROM CalendarEventAttendee WHERE CalendarItemId = ?",
calendarItemId);
// Insert new attendees.
connection.InsertAll(allAttendees, typeof(CalendarEventAttendee));
});
return await Connection.Table<CalendarEventAttendee>().Where(a => a.CalendarItemId == calendarItemId).ToListAsync();
}
public async Task<CalendarItem> GetCalendarItemTargetAsync(CalendarItemTarget targetDetails)
{
var eventId = targetDetails.Item.Id;
// Get the event by Id first (this already loads AssignedCalendar).
var item = await GetCalendarItemAsync(eventId).ConfigureAwait(false);
bool isRecurringChild = targetDetails.Item.IsRecurringChild;
bool isRecurringParent = targetDetails.Item.IsRecurringParent;
if (targetDetails.TargetType == CalendarEventTargetType.Single)
{
if (isRecurringChild)
{
if (item == null)
{
// This occurrence doesn't exist in db - return the passed item.
// Ensure AssignedCalendar is loaded.
await LoadAssignedCalendarAsync(targetDetails.Item);
return targetDetails.Item;
}
else
{
// Single exception occurrence of recurring event.
// Return the item.
return item;
}
}
else if (isRecurringParent)
{
// Parent recurring events are never listed.
Debugger.Break();
return null;
}
else
{
// Single event.
return item;
}
}
else
{
// Series.
if (isRecurringChild)
{
// Return the parent.
return await GetCalendarItemAsync(targetDetails.Item.RecurringCalendarItemId.Value).ConfigureAwait(false);
}
else if (isRecurringParent)
return item;
else
{
// NA. Single events don't have series.
Debugger.Break();
return null;
}
}
}
/// <summary>
/// Gets reminders for a calendar item.
/// </summary>
public Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId)
=> Connection.Table<Reminder>().Where(r => r.CalendarItemId == calendarItemId).ToListAsync();
public async Task SaveRemindersAsync(Guid calendarItemId, List<Reminder> reminders)
{
await Connection.RunInTransactionAsync((connection) =>
{
// Clear existing reminders for this calendar item
connection.Execute(
"DELETE FROM Reminder WHERE CalendarItemId = ?",
calendarItemId);
// Insert new reminders if any
if (reminders != null && reminders.Count > 0)
{
connection.InsertAll(reminders, typeof(Reminder));
}
});
}
public Task SnoozeCalendarItemAsync(Guid calendarItemId, DateTime snoozedUntilLocal)
=> Connection.ExecuteAsync(
$"UPDATE {nameof(CalendarItem)} SET {nameof(CalendarItem.SnoozedUntil)} = ? WHERE {nameof(CalendarItem.Id)} = ?",
snoozedUntilLocal,
calendarItemId);
public async Task<List<CalendarReminderNotificationRequest>> CheckAndNotifyAsync(DateTime lastCheckLocal, DateTime nowLocal, ISet<string> sentReminderKeys, CancellationToken cancellationToken = default)
{
if (sentReminderKeys == null)
return [];
var candidates = await Connection.QueryAsync<CalendarReminderCandidate>(
@"
SELECT
c.Id AS CalendarItemId,
c.StartDate,
c.StartTimeZone,
r.DurationInSeconds AS ReminderDurationInSeconds,
c.SnoozedUntil
FROM CalendarItem c
INNER JOIN Reminder r ON r.CalendarItemId = c.Id
INNER JOIN AccountCalendar ac ON ac.Id = c.CalendarId
INNER JOIN MailAccount ma ON ma.Id = ac.AccountId
WHERE
c.IsHidden = 0
AND ma.IsCalendarAccessGranted = 1
AND r.ReminderType = 0
AND NOT (IFNULL(c.Recurrence, '') != '' AND c.RecurringCalendarItemId IS NULL)")
.ConfigureAwait(false);
var dueNotifications = new List<CalendarReminderNotificationRequest>();
foreach (var candidate in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var eventStartLocal = candidate.StartDate.ToLocalTimeFromTimeZone(candidate.StartTimeZone);
var triggerTimeLocal = eventStartLocal.AddSeconds(-candidate.ReminderDurationInSeconds);
var effectiveTriggerTimeLocal = candidate.SnoozedUntil.HasValue
? MaxDateTime(triggerTimeLocal, candidate.SnoozedUntil.Value)
: triggerTimeLocal;
if (effectiveTriggerTimeLocal <= lastCheckLocal || effectiveTriggerTimeLocal > nowLocal)
continue;
var reminderKey = $"{candidate.CalendarItemId:N}:{candidate.ReminderDurationInSeconds}:{effectiveTriggerTimeLocal.Ticks}";
if (!sentReminderKeys.Add(reminderKey))
continue;
var calendarItem = await GetCalendarItemAsync(candidate.CalendarItemId).ConfigureAwait(false);
if (calendarItem == null)
continue;
dueNotifications.Add(new CalendarReminderNotificationRequest()
{
CalendarItem = calendarItem,
ReminderDurationInSeconds = candidate.ReminderDurationInSeconds,
ReminderKey = reminderKey
});
}
return dueNotifications;
}
#region Attachments
public Task<List<CalendarAttachment>> GetAttachmentsAsync(Guid calendarItemId)
=> Connection.Table<CalendarAttachment>().Where(x => x.CalendarItemId == calendarItemId).ToListAsync();
public async Task InsertOrReplaceAttachmentsAsync(List<CalendarAttachment> attachments)
{
if (attachments == null || attachments.Count == 0) return;
foreach (var item in attachments)
{
// Check if an attachment with the same RemoteAttachmentId already exists for this calendar item
// to avoid re-downloading already existing attachments.
var existingAttachment = await Connection.Table<CalendarAttachment>()
.FirstOrDefaultAsync(x => x.CalendarItemId == item.CalendarItemId
&& x.RemoteAttachmentId == item.RemoteAttachmentId);
if (existingAttachment != null)
{
// Preserve the existing Id, IsDownloaded status, and LocalFilePath
item.Id = existingAttachment.Id;
item.IsDownloaded = existingAttachment.IsDownloaded;
item.LocalFilePath = existingAttachment.LocalFilePath;
}
await Connection.InsertOrReplaceAsync(item, typeof(CalendarAttachment));
}
}
public async Task MarkAttachmentDownloadedAsync(Guid attachmentId, string localFilePath)
{
var attachment = await Connection.Table<CalendarAttachment>().FirstOrDefaultAsync(x => x.Id == attachmentId);
if (attachment == null) return;
attachment.IsDownloaded = true;
attachment.LocalFilePath = localFilePath;
await Connection.UpdateAsync(attachment, typeof(CalendarAttachment));
}
public async Task DeleteAttachmentsAsync(Guid calendarItemId)
{
await Connection.ExecuteAsync("DELETE FROM CalendarAttachment WHERE CalendarItemId = ?", calendarItemId);
}
#endregion
private static DateTime MaxDateTime(DateTime first, DateTime second)
=> first >= second ? first : second;
private sealed class CalendarReminderCandidate
{
public Guid CalendarItemId { get; set; }
public DateTime StartDate { get; set; }
public string StartTimeZone { get; set; }
public long ReminderDurationInSeconds { get; set; }
public DateTime? SnoozedUntil { get; set; }
}
}