From 1ee0063b620086df8cf708180bfdce9631ed7215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 6 Jul 2025 17:25:38 +0200 Subject: [PATCH] Refactored the calendar synchronization code using AI. --- .../CalendarPageViewModel.cs | 54 +- .../Data/CalendarItemViewModel.cs | 15 +- .../EventDetailsPageViewModel.cs | 4 +- .../Controls/CalendarItemControl.xaml | 4 +- .../Controls/CalendarItemControl.xaml.cs | 13 +- Wino.Calendar/Controls/WinoCalendarPanel.cs | 19 +- Wino.Calendar/Helpers/CalendarXamlHelpers.cs | 57 +- .../CustomAreaCalendarItemSelector.cs | 3 +- Wino.Calendar/Views/EventDetailsPage.xaml | 4 +- .../Collections/CalendarEventCollection.cs | 4 +- Wino.Core.Domain/Constants.cs | 2 - .../Entities/Calendar/AccountCalendar.cs | 39 +- .../Calendar/CalendarEventAttendee.cs | 33 +- .../Entities/Calendar/CalendarItem.cs | 205 +-- .../Enums/AttendeeResponseStatus.cs | 32 + Wino.Core.Domain/Enums/CalendarItemType.cs | 49 + .../Helpers/CalendarItemTypeHelper.cs | 143 +++ Wino.Core.Domain/Interfaces/ICalendarItem.cs | 14 +- .../Interfaces/ICalendarServiceEx.cs | 55 + .../Extensions/GoogleIntegratorExtensions.cs | 129 +- .../Extensions/OutlookIntegratorExtensions.cs | 148 +-- .../Processors/DefaultChangeProcessor.cs | 237 +++- .../Processors/GmailChangeProcessor.cs | 227 +--- .../Processors/ImapChangeProcessor.cs | 3 +- .../Processors/OutlookChangeProcessor.cs | 109 +- Wino.Core/Synchronizers/GmailSynchronizer.cs | 270 +++- .../Synchronizers/OutlookSynchronizer.cs | 1060 ++++++++++++++-- Wino.Services/CalendarService.cs | 171 +-- Wino.Services/CalendarServiceEx.cs | 1105 +++++++++++++++++ Wino.Services/ServicesContainerSetup.cs | 3 +- 30 files changed, 3125 insertions(+), 1086 deletions(-) create mode 100644 Wino.Core.Domain/Enums/AttendeeResponseStatus.cs create mode 100644 Wino.Core.Domain/Enums/CalendarItemType.cs create mode 100644 Wino.Core.Domain/Helpers/CalendarItemTypeHelper.cs create mode 100644 Wino.Core.Domain/Interfaces/ICalendarServiceEx.cs create mode 100644 Wino.Services/CalendarServiceEx.cs diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 99cd7a94..606a951a 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -127,6 +127,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private readonly ICalendarService _calendarService; private readonly INavigationService _navigationService; private readonly IKeyPressService _keyPressService; + private readonly ICalendarServiceEx _calendarServiceEx; private readonly IPreferencesService _preferencesService; // Store latest rendered options. @@ -146,6 +147,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, ICalendarService calendarService, INavigationService navigationService, IKeyPressService keyPressService, + ICalendarServiceEx calendarServiceEx, IAccountCalendarStateService accountCalendarStateService, IPreferencesService preferencesService) { @@ -155,6 +157,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, _calendarService = calendarService; _navigationService = navigationService; _keyPressService = keyPressService; + _calendarServiceEx = calendarServiceEx; _preferencesService = preferencesService; AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested; @@ -235,23 +238,6 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(CanSaveQuickEvent))] private async Task SaveQuickEventAsync() { - var durationSeconds = (QuickEventEndTime - QuickEventStartTime).TotalSeconds; - - var testCalendarItem = new CalendarItem - { - CalendarId = SelectedQuickEventAccountCalendar.Id, - StartDate = QuickEventStartTime, - DurationInSeconds = durationSeconds, - CreatedAt = DateTime.UtcNow, - Description = string.Empty, - Location = Location, - Title = EventName, - Id = Guid.NewGuid() - }; - - IsQuickEventDialogOpen = false; - await _calendarService.CreateNewCalendarItemAsync(testCalendarItem, null); - // TODO: Create the request with the synchronizer. } @@ -635,10 +621,13 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, // Check all the events for the given date range and calendar. // Then find the day representation for all the events returned, and add to the collection. - var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, dayRangeRenderModel).ConfigureAwait(false); + var events = await _calendarServiceEx.GetExpandedEventsInDateRangeWithExceptionsAsync(dayRangeRenderModel.Period.Start, dayRangeRenderModel.Period.End, calendarViewModel.AccountCalendar).ConfigureAwait(false); foreach (var @event in events) { + // TODO: Do it in the service. + @event.AssignedCalendar = calendarViewModel.AccountCalendar; + // Find the days that the event falls into. var allDaysForEvent = dayRangeRenderModel.CalendarDays.Where(a => a.Period.OverlapsWith(@event.Period)); @@ -800,19 +789,22 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, // Recurring events must be selected as a single instance. // We need to find the day that the event is in, and then select the event. - if (!calendarItemViewModel.IsRecurringEvent) - { - return [calendarItemViewModel]; - } - else - { - return DayRanges - .SelectMany(a => a.CalendarDays) - .Select(b => b.EventsCollection.GetCalendarItem(calendarItemViewModel.Id)) - .Where(c => c != null) - .Cast() - .Distinct(); - } + // TODO: Implement below logic. + return default; + + //if (!calendarItemViewModel.IsRecurringEvent) + //{ + // return [calendarItemViewModel]; + //} + //else + //{ + // return DayRanges + // .SelectMany(a => a.CalendarDays) + // .Select(b => b.EventsCollection.GetCalendarItem(calendarItemViewModel.Id)) + // .Where(c => c != null) + // .Cast() + // .Distinct(); + //} } private void UnselectCalendarItem(CalendarItemViewModel calendarItemViewModel, CalendarDayModel calendarDay = null) diff --git a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs index f402a562..6f69aa24 100644 --- a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using Itenso.TimePeriod; using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; namespace Wino.Calendar.ViewModels.Data; @@ -17,25 +18,21 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar; - public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; } + public DateTime StartDateTime { get => CalendarItem.StartDateTime; set => CalendarItem.StartDateTime = value; } - public DateTime EndDate => CalendarItem.EndDate; - - public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; } + public DateTime EndDateTime => CalendarItem.EndDateTime; public ITimePeriod Period => CalendarItem.Period; - public bool IsAllDayEvent => CalendarItem.IsAllDayEvent; - public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent; - public bool IsRecurringEvent => CalendarItem.IsRecurringEvent; - public bool IsRecurringChild => CalendarItem.IsRecurringChild; - public bool IsRecurringParent => CalendarItem.IsRecurringParent; + public bool IsRecurringEvent => !string.IsNullOrEmpty(CalendarItem.RecurrenceRules) || !string.IsNullOrEmpty(CalendarItem.RecurringEventId); [ObservableProperty] private bool _isSelected; public ObservableCollection Attendees { get; } = new ObservableCollection(); + public CalendarItemType ItemType => ((ICalendarItem)CalendarItem).ItemType; + public CalendarItemViewModel(CalendarItem calendarItem) { CalendarItem = calendarItem; diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index f57d797e..7d886156 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -31,7 +31,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel [ObservableProperty] private CalendarItemViewModel _seriesParent; - public bool CanViewSeries => CurrentEvent?.IsRecurringChild ?? false; + public bool CanViewSeries => false; //CurrentEvent?.IsRecurringChild ?? false; // TODO: Implement this properly #endregion @@ -67,7 +67,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel CurrentEvent = new CalendarItemViewModel(currentEventItem); - var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.EventTrackingId); + var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.Id); foreach (var item in attendees) { diff --git a/Wino.Calendar/Controls/CalendarItemControl.xaml b/Wino.Calendar/Controls/CalendarItemControl.xaml index abfc1766..3e7d1ebf 100644 --- a/Wino.Calendar/Controls/CalendarItemControl.xaml +++ b/Wino.Calendar/Controls/CalendarItemControl.xaml @@ -69,7 +69,7 @@ VerticalAlignment="Top" Orientation="Horizontal" Spacing="6"> - + Visibility="{x:Bind CalendarItem.IsMultiDayEvent, Mode=OneWay}" />--> diff --git a/Wino.Calendar/Controls/CalendarItemControl.xaml.cs b/Wino.Calendar/Controls/CalendarItemControl.xaml.cs index dbd2333b..ca42cf19 100644 --- a/Wino.Calendar/Controls/CalendarItemControl.xaml.cs +++ b/Wino.Calendar/Controls/CalendarItemControl.xaml.cs @@ -7,6 +7,7 @@ using Windows.UI.Xaml.Input; using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Messages; using Wino.Core.Domain; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Models.Calendar; namespace Wino.Calendar.Controls; @@ -90,7 +91,9 @@ public sealed partial class CalendarItemControl : UserControl if (CalendarItem == null) return; if (DisplayingDate == null) return; - if (CalendarItem.IsMultiDayEvent) + bool isMultiDayEvent = CalendarItem.CalendarItem.ItemType == CalendarItemType.MultiDay || CalendarItem.CalendarItem.ItemType == CalendarItemType.MultiDayAllDay; + + if (isMultiDayEvent) { // Multi day events are divided into 3 categories: // 1. All day events @@ -103,14 +106,14 @@ public sealed partial class CalendarItemControl : UserControl periodRelation == PeriodRelation.EnclosingStartTouching) { // hour -> title - CalendarItemTitle = $"{DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.StartDate.TimeOfDay)} -> {CalendarItem.Title}"; + CalendarItemTitle = $"{DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.StartDateTime.TimeOfDay)} -> {CalendarItem.Title}"; } else if ( periodRelation == PeriodRelation.EndInside || periodRelation == PeriodRelation.EnclosingEndTouching) { // title <- hour - CalendarItemTitle = $"{CalendarItem.Title} <- {DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.EndDate.TimeOfDay)}"; + CalendarItemTitle = $"{CalendarItem.Title} <- {DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.EndDateTime.TimeOfDay)}"; } else if (periodRelation == PeriodRelation.Enclosing) { @@ -139,11 +142,11 @@ public sealed partial class CalendarItemControl : UserControl { if (CalendarItem == null) return; - if (CalendarItem.IsAllDayEvent) + if (CalendarItem.CalendarItem.ItemType == CalendarItemType.AllDay) { VisualStateManager.GoToState(this, "AllDayEvent", true); } - else if (CalendarItem.IsMultiDayEvent) + else if (CalendarItem.CalendarItem.ItemType == CalendarItemType.MultiDayAllDay || CalendarItem.CalendarItem.ItemType == CalendarItemType.MultiDay) { if (IsCustomEventArea) { diff --git a/Wino.Calendar/Controls/WinoCalendarPanel.cs b/Wino.Calendar/Controls/WinoCalendarPanel.cs index c76801ea..68e99283 100644 --- a/Wino.Calendar/Controls/WinoCalendarPanel.cs +++ b/Wino.Calendar/Controls/WinoCalendarPanel.cs @@ -9,6 +9,7 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Wino.Calendar.Models; using Wino.Calendar.ViewModels.Data; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; namespace Wino.Calendar.Controls; @@ -46,7 +47,7 @@ public partial class WinoCalendarPanel : Panel private double GetChildTopMargin(ICalendarItem calendarItemViewModel, double availableHeight) { - var childStart = calendarItemViewModel.StartDate; + var childStart = calendarItemViewModel.StartDateTime; if (childStart <= Period.Start) { @@ -71,7 +72,7 @@ public partial class WinoCalendarPanel : Panel private double GetChildHeight(ICalendarItem child) { // All day events are not measured. - if (child.IsAllDayEvent) return 0; + if (child.ItemType == CalendarItemType.AllDay) return 0; double childDurationInMinutes = 0d; double availableHeight = HourHeight * 24; @@ -80,7 +81,7 @@ public partial class WinoCalendarPanel : Panel // Debug.WriteLine($"Render relation of {child.Title} ({child.Period.Start} - {child.Period.End}) is {periodRelation} with {Period.Start.Day}"); - if (!child.IsMultiDayEvent) + if (child.ItemType != CalendarItemType.MultiDay || child.ItemType != CalendarItemType.MultiDayAllDay) { childDurationInMinutes = child.Period.Duration.TotalMinutes; } @@ -160,7 +161,7 @@ public partial class WinoCalendarPanel : Panel double extraRightMargin = 0; // Multi-day events don't have any margin and their hit test is disabled. - if (!child.IsMultiDayEvent) + if (child.ItemType != CalendarItemType.MultiDay || child.ItemType != CalendarItemType.MultiDayAllDay) { // Max of 5% of the width or 20px max. extraRightMargin = isHorizontallyLastItem ? Math.Max(LastItemRightExtraMargin, finalSize.Width * 5 / 100) : 0; @@ -168,8 +169,10 @@ public partial class WinoCalendarPanel : Panel if (childWidth < 0) childWidth = 1; + bool isAllOrMultiDayEvent = child.ItemType == CalendarItemType.AllDay || child.ItemType == CalendarItemType.MultiDay || child.ItemType == CalendarItemType.MultiDayAllDay; + // Regular events must have 2px margin - if (!child.IsMultiDayEvent && !child.IsAllDayEvent) + if (!isAllOrMultiDayEvent) { childLeft += 2; childTop += 2; @@ -211,10 +214,12 @@ public partial class WinoCalendarPanel : Panel var columns = new List>(); DateTime? lastEventEnding = null; - foreach (var ev in events.OrderBy(ev => ev.StartDate).ThenBy(ev => ev.EndDate)) + foreach (var ev in events.OrderBy(ev => ev.StartDateTime).ThenBy(ev => ev.EndDateTime)) { // Multi-day events are not measured. - if (ev.IsMultiDayEvent) continue; + bool isMultiDayEvent = ev.ItemType == CalendarItemType.MultiDay || ev.ItemType == CalendarItemType.MultiDayAllDay; + + if (isMultiDayEvent) continue; if (ev.Period.Start >= lastEventEnding) { diff --git a/Wino.Calendar/Helpers/CalendarXamlHelpers.cs b/Wino.Calendar/Helpers/CalendarXamlHelpers.cs index cdc7c4d8..36d0d13c 100644 --- a/Wino.Calendar/Helpers/CalendarXamlHelpers.cs +++ b/Wino.Calendar/Helpers/CalendarXamlHelpers.cs @@ -22,6 +22,7 @@ public static class CalendarXamlHelpers /// public static string GetEventDetailsDateString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) { + // TODO: This is not correct. if (calendarItemViewModel == null || settings == null) return string.Empty; var start = calendarItemViewModel.Period.Start; @@ -30,7 +31,7 @@ public static class CalendarXamlHelpers string timeFormat = settings.DayHeaderDisplayType == DayHeaderDisplayType.TwelveHour ? "h:mm tt" : "HH:mm"; string dateFormat = settings.DayHeaderDisplayType == DayHeaderDisplayType.TwelveHour ? "dddd, dd MMMM h:mm tt" : "dddd, dd MMMM HH:mm"; - if (calendarItemViewModel.IsMultiDayEvent) + if (calendarItemViewModel.CalendarItem.ItemType == CalendarItemType.MultiDay || calendarItemViewModel.CalendarItem.ItemType == CalendarItemType.MultiDayAllDay) { return $"{start.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)} - {end.ToString($"dd MMMM ddd {timeFormat}", settings.CultureInfo)}"; } @@ -42,54 +43,14 @@ public static class CalendarXamlHelpers public static string GetRecurrenceString(CalendarItemViewModel calendarItemViewModel) { - if (calendarItemViewModel == null || !calendarItemViewModel.IsRecurringChild) return string.Empty; - - // Parse recurrence rules - var calendarEvent = new CalendarEvent - { - Start = new CalDateTime(calendarItemViewModel.StartDate), - End = new CalDateTime(calendarItemViewModel.EndDate), - }; - - var recurrenceLines = Regex.Split(calendarItemViewModel.CalendarItem.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator); - - foreach (var line in recurrenceLines) - { - calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line)); - } - - if (calendarEvent.RecurrenceRules == null || !calendarEvent.RecurrenceRules.Any()) - { - return "No recurrence pattern."; - } - - var recurrenceRule = calendarEvent.RecurrenceRules.First(); - var daysOfWeek = string.Join(", ", recurrenceRule.ByDay.Select(day => day.DayOfWeek.ToString())); - string timeZone = calendarEvent.DtStart.TzId ?? "UTC"; - - return $"Every {daysOfWeek}, effective {calendarEvent.DtStart.Value.ToShortDateString()} " + - $"from {calendarEvent.DtStart.Value.ToShortTimeString()} to {calendarEvent.DtEnd.Value.ToShortTimeString()} " + - $"{timeZone}."; + // TODO + return string.Empty; } public static string GetDetailsPopupDurationString(CalendarItemViewModel calendarItemViewModel, CalendarSettings settings) { - if (calendarItemViewModel == null || settings == null) return string.Empty; - - // Single event in a day. - if (!calendarItemViewModel.IsAllDayEvent && !calendarItemViewModel.IsMultiDayEvent) - { - return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} {settings.GetTimeString(calendarItemViewModel.Period.Duration)}"; - } - else if (calendarItemViewModel.IsMultiDayEvent) - { - return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} - {calendarItemViewModel.Period.End.ToString("d", settings.CultureInfo)}"; - } - else - { - // All day event. - return $"{calendarItemViewModel.Period.Start.ToString("d", settings.CultureInfo)} ({Translator.CalendarItemAllDay})"; - } + // TODO + return string.Empty; } public static PopupPlacementMode GetDesiredPlacementModeForEventsDetailsPopup( @@ -98,8 +59,12 @@ public static class CalendarXamlHelpers { if (calendarItemViewModel == null) return PopupPlacementMode.Auto; + bool isAllDayOrMultiDay = calendarItemViewModel.CalendarItem.ItemType == CalendarItemType.MultiDay || + calendarItemViewModel.CalendarItem.ItemType == CalendarItemType.AllDay || + calendarItemViewModel.CalendarItem.ItemType == CalendarItemType.MultiDayAllDay; + // All and/or multi day events always go to the top of the screen. - if (calendarItemViewModel.IsAllDayEvent || calendarItemViewModel.IsMultiDayEvent) return PopupPlacementMode.Bottom; + if (isAllDayOrMultiDay) return PopupPlacementMode.Bottom; return XamlHelpers.GetPlaccementModeForCalendarType(calendarDisplayType); } diff --git a/Wino.Calendar/Selectors/CustomAreaCalendarItemSelector.cs b/Wino.Calendar/Selectors/CustomAreaCalendarItemSelector.cs index 253a82a6..d17d729a 100644 --- a/Wino.Calendar/Selectors/CustomAreaCalendarItemSelector.cs +++ b/Wino.Calendar/Selectors/CustomAreaCalendarItemSelector.cs @@ -13,7 +13,8 @@ public partial class CustomAreaCalendarItemSelector : DataTemplateSelector { if (item is CalendarItemViewModel calendarItemViewModel) { - return calendarItemViewModel.IsMultiDayEvent ? MultiDayTemplate : AllDayTemplate; + return calendarItemViewModel.CalendarItem.ItemType == Core.Domain.Enums.CalendarItemType.MultiDay || + calendarItemViewModel.CalendarItem.ItemType == Core.Domain.Enums.CalendarItemType.MultiDayAllDay ? MultiDayTemplate : AllDayTemplate; } return base.SelectTemplateCore(item, container); diff --git a/Wino.Calendar/Views/EventDetailsPage.xaml b/Wino.Calendar/Views/EventDetailsPage.xaml index 875b5689..0aa80dc1 100644 --- a/Wino.Calendar/Views/EventDetailsPage.xaml +++ b/Wino.Calendar/Views/EventDetailsPage.xaml @@ -256,7 +256,7 @@ + DisplayName="{x:Bind DisplayName}" /> @@ -265,7 +265,7 @@ - + @@ -20,5 +48,4 @@ public class AccountCalendar : IAccountCalendar /// public string TextColorHex { get; set; } public string BackgroundColorHex { get; set; } - public string TimeZone { get; set; } } diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs b/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs index e9c47da3..1080b2f4 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs @@ -8,12 +8,29 @@ namespace Wino.Core.Domain.Entities.Calendar; public class CalendarEventAttendee { [PrimaryKey] - public Guid Id { get; set; } - public Guid CalendarItemId { get; set; } - public string Name { get; set; } - public string Email { get; set; } - public AttendeeStatus AttendenceStatus { get; set; } - public bool IsOrganizer { get; set; } - public bool IsOptionalAttendee { get; set; } - public string Comment { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); + + [NotNull] + public Guid EventId { get; set; } + + [NotNull] + public string Email { get; set; } = string.Empty; + + public string? DisplayName { get; set; } + + public AttendeeResponseStatus ResponseStatus { get; set; } = AttendeeResponseStatus.NeedsAction; + + public bool IsOptional { get; set; } = false; + + public bool IsOrganizer { get; set; } = false; + + public bool IsSelf { get; set; } = false; + + public string? Comment { get; set; } + + public int? AdditionalGuests { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime LastModified { get; set; } } diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index de03e74e..49ef18c8 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Itenso.TimePeriod; using SQLite; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Helpers; using Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Entities.Calendar; @@ -11,169 +12,81 @@ namespace Wino.Core.Domain.Entities.Calendar; public class CalendarItem : ICalendarItem { [PrimaryKey] - public Guid Id { get; set; } - public string RemoteEventId { get; set; } - public string Title { get; set; } - public string Description { get; set; } - public string Location { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); - public DateTime StartDate { get; set; } + [NotNull] + public string RemoteEventId { get; set; } = string.Empty; - public DateTime EndDate - { - get - { - return StartDate.AddSeconds(DurationInSeconds); - } - } + [NotNull] + public Guid CalendarId { get; set; } - public TimeSpan StartDateOffset { get; set; } - public TimeSpan EndDateOffset { get; set; } + [Ignore] + public IAccountCalendar AssignedCalendar { get; set; } + + [NotNull] + public string Title { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string? Location { get; set; } + public string? HtmlLink { get; set; } + + public DateTime StartDateTime { get; set; } + + public DateTime EndDateTime { get; set; } private ITimePeriod _period; public ITimePeriod Period { get { - _period ??= new TimeRange(StartDate, EndDate); + _period ??= new TimeRange(StartDateTime, EndDateTime); return _period; } } + public bool IsAllDay { get; set; } + + public string? TimeZone { get; set; } + + public string? RecurrenceRules { get; set; } + + public string? Status { get; set; } + + public string? OrganizerDisplayName { get; set; } + + public string? OrganizerEmail { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime LastModified { get; set; } + + public bool IsDeleted { get; set; } + + public string? RecurringEventId { get; set; } + + public string? OriginalStartTime { get; set; } + /// - /// Events that starts at midnight and ends at midnight are considered all-day events. + /// The type of calendar item (Timed, AllDay, MultiDay, etc.) /// - public bool IsAllDayEvent + public CalendarItemType ItemType { get; set; } = CalendarItemType.Timed; + + /// + /// Automatically determines and sets the ItemType based on event properties + /// + public void DetermineItemType() { - get - { - return - StartDate.TimeOfDay == TimeSpan.Zero && - EndDate.TimeOfDay == TimeSpan.Zero; - } - } + var hasRecurrence = !string.IsNullOrEmpty(RecurrenceRules); + var isCancelled = Status?.ToLowerInvariant() == "cancelled" || IsDeleted; - /// - /// Events that are either an exceptional instance of a recurring event or occurrences. - /// IsOccurrence is used to display occurrence instances of parent recurring events. - /// IsOccurrence == false && IsRecurringChild == true => exceptional single instance. - /// - public bool IsRecurringChild - { - get - { - return RecurringCalendarItemId != null; - } - } - - /// - /// Events that are either an exceptional instance of a recurring event or occurrences. - /// - public bool IsRecurringEvent => IsRecurringChild || IsRecurringParent; - - /// - /// Events that are the master event definition of recurrence events. - /// - public bool IsRecurringParent - { - get - { - return !string.IsNullOrEmpty(Recurrence) && RecurringCalendarItemId == null; - } - } - - /// - /// Events that are not all-day events and last more than one day are considered multi-day events. - /// - public bool IsMultiDayEvent - { - get - { - return Period.Duration.TotalDays >= 1 && !IsAllDayEvent; - } - } - - public double DurationInSeconds { get; set; } - public string Recurrence { get; set; } - - public string OrganizerDisplayName { get; set; } - public string OrganizerEmail { get; set; } - - /// - /// The id of the parent calendar item of the recurring event. - /// Exceptional instances are stored as a separate calendar item. - /// This makes the calendar item a child of the recurring event. - /// - public Guid? RecurringCalendarItemId { get; set; } - - /// - /// Indicates read-only events. Default is false. - /// - public bool IsLocked { get; set; } - - /// - /// Hidden events must not be displayed to the user. - /// This usually happens when a child instance of recurring parent is cancelled after creation. - /// - public bool IsHidden { get; set; } - - // TODO - public string CustomEventColorHex { get; set; } - public string HtmlLink { get; set; } - public CalendarItemStatus Status { get; set; } - public CalendarItemVisibility Visibility { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset UpdatedAt { get; set; } - public Guid CalendarId { get; set; } - - [Ignore] - public IAccountCalendar AssignedCalendar { get; set; } - - /// - /// Whether this item does not really exist in the database or not. - /// These are used to display occurrence instances of parent recurring events. - /// - [Ignore] - public bool IsOccurrence { get; set; } - - /// - /// Id to load information related to this event. - /// Occurrences tracked by the parent recurring event if they are not exceptional instances. - /// Recurring children here are exceptional instances. They have their own info in the database including Id. - /// - public Guid EventTrackingId => IsOccurrence ? RecurringCalendarItemId.Value : Id; - - public CalendarItem CreateRecurrence(DateTime startDate, double durationInSeconds) - { - // Create a copy with the new start date and duration - - return new CalendarItem - { - Id = Guid.NewGuid(), - Title = Title, - Description = Description, - Location = Location, - StartDate = startDate, - DurationInSeconds = durationInSeconds, - Recurrence = Recurrence, - OrganizerDisplayName = OrganizerDisplayName, - OrganizerEmail = OrganizerEmail, - RecurringCalendarItemId = Id, - AssignedCalendar = AssignedCalendar, - CalendarId = CalendarId, - CreatedAt = CreatedAt, - UpdatedAt = UpdatedAt, - Visibility = Visibility, - Status = Status, - CustomEventColorHex = CustomEventColorHex, - HtmlLink = HtmlLink, - StartDateOffset = StartDateOffset, - EndDateOffset = EndDateOffset, - RemoteEventId = RemoteEventId, - IsHidden = IsHidden, - IsLocked = IsLocked, - IsOccurrence = true - }; + ItemType = CalendarItemTypeHelper.DetermineItemType( + StartDateTime, + EndDateTime, + IsAllDay, + hasRecurrence, + isCancelled, + Status); } } diff --git a/Wino.Core.Domain/Enums/AttendeeResponseStatus.cs b/Wino.Core.Domain/Enums/AttendeeResponseStatus.cs new file mode 100644 index 00000000..6cceffc1 --- /dev/null +++ b/Wino.Core.Domain/Enums/AttendeeResponseStatus.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Enums; +/// +/// Represents the response status of an attendee to a calendar event +/// +public enum AttendeeResponseStatus +{ + /// + /// The attendee has not responded to the invitation + /// + NeedsAction = 0, + + /// + /// The attendee has accepted the invitation + /// + Accepted = 1, + + /// + /// The attendee has declined the invitation + /// + Declined = 2, + + /// + /// The attendee has tentatively accepted the invitation + /// + Tentative = 3 +} diff --git a/Wino.Core.Domain/Enums/CalendarItemType.cs b/Wino.Core.Domain/Enums/CalendarItemType.cs new file mode 100644 index 00000000..eac14f77 --- /dev/null +++ b/Wino.Core.Domain/Enums/CalendarItemType.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Enums; +public enum CalendarItemType +{ + /// + /// A standard timed event with specific start and end times on the same day + /// + Timed = 0, + + /// + /// An all-day event that spans exactly one day + /// + AllDay = 1, + + /// + /// A multi-day event that spans more than one day but has specific times + /// + MultiDay = 2, + + /// + /// A multi-day all-day event (e.g., vacation, conference spanning multiple days) + /// + MultiDayAllDay = 3, + + /// + /// A recurring event with a defined pattern (daily, weekly, monthly, yearly) + /// + Recurring = 4, + + /// + /// A recurring all-day event (e.g., annual holiday, weekly all-day event) + /// + RecurringAllDay = 5, + + /// + /// A single instance of a recurring event that has been modified + /// + RecurringException = 6, + + /// + /// An event that extends beyond midnight but is not multi-day (e.g., 11 PM to 2 AM) + /// + CrossMidnight = 7, +} diff --git a/Wino.Core.Domain/Helpers/CalendarItemTypeHelper.cs b/Wino.Core.Domain/Helpers/CalendarItemTypeHelper.cs new file mode 100644 index 00000000..e3da9a24 --- /dev/null +++ b/Wino.Core.Domain/Helpers/CalendarItemTypeHelper.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Helpers; +/// +/// Helper class for CalendarItemType operations +/// +public static class CalendarItemTypeHelper +{ + /// + /// Determines the calendar item type based on event properties + /// + /// Event start date/time + /// Event end date/time + /// Whether the event is marked as all-day + /// Whether the event has recurrence rules + /// Whether the event is cancelled + /// Event status + /// The appropriate CalendarItemType + public static CalendarItemType DetermineItemType( + DateTime startDateTime, + DateTime endDateTime, + bool isAllDay, + bool isRecurring = false, + bool isCancelled = false, + string? status = null) + { + // Handle recurring events + if (isRecurring) + { + return isAllDay ? CalendarItemType.RecurringAllDay : CalendarItemType.Recurring; + } + + // Handle all-day events + if (isAllDay) + { + var daySpan = (endDateTime.Date - startDateTime.Date).Days; + return daySpan > 1 ? CalendarItemType.MultiDayAllDay : CalendarItemType.AllDay; + } + + // Handle timed events + var duration = endDateTime - startDateTime; + + + + // Multi-day timed events + if (duration.TotalDays >= 1) + { + return CalendarItemType.MultiDay; + } + + // Cross midnight events (same calendar day but extends past midnight) + if (startDateTime.Date != endDateTime.Date && duration.TotalHours <= 24) + { + return CalendarItemType.CrossMidnight; + } + + // Standard timed events + return CalendarItemType.Timed; + } + + /// + /// Gets a human-readable description of the calendar item type + /// + /// The calendar item type + /// Description string + public static string GetDescription(CalendarItemType itemType) + { + return itemType switch + { + CalendarItemType.Timed => "Timed Event", + CalendarItemType.AllDay => "All-Day Event", + CalendarItemType.MultiDay => "Multi-Day Event", + CalendarItemType.MultiDayAllDay => "Multi-Day All-Day Event", + CalendarItemType.Recurring => "Recurring Event", + CalendarItemType.RecurringAllDay => "Recurring All-Day Event", + CalendarItemType.RecurringException => "Modified Recurring Event", + CalendarItemType.CrossMidnight => "Cross-Midnight Event", + _ => "Unknown Event Type" + }; + } + + /// + /// Checks if the event type represents an all-day event + /// + /// The calendar item type + /// True if it's an all-day event type + public static bool IsAllDayType(CalendarItemType itemType) + { + return itemType == CalendarItemType.AllDay || + itemType == CalendarItemType.MultiDayAllDay || + itemType == CalendarItemType.RecurringAllDay; + } + + /// + /// Checks if the event type represents a recurring event + /// + /// The calendar item type + /// True if it's a recurring event type + public static bool IsRecurringType(CalendarItemType itemType) + { + return itemType == CalendarItemType.Recurring || + itemType == CalendarItemType.RecurringAllDay || + itemType == CalendarItemType.RecurringException; + } + + /// + /// Checks if the event type represents a multi-day event + /// + /// The calendar item type + /// True if it's a multi-day event type + public static bool IsMultiDayType(CalendarItemType itemType) + { + return itemType == CalendarItemType.MultiDay || + itemType == CalendarItemType.MultiDayAllDay; + } + + /// + /// Gets the priority level for sorting events (lower number = higher priority) + /// + /// The calendar item type + /// Priority number for sorting + public static int GetSortPriority(CalendarItemType itemType) + { + return itemType switch + { + + CalendarItemType.AllDay => 2, + CalendarItemType.MultiDayAllDay => 3, + CalendarItemType.Timed => 4, + CalendarItemType.CrossMidnight => 5, + CalendarItemType.MultiDay => 6, + CalendarItemType.Recurring => 7, + CalendarItemType.RecurringAllDay => 8, + CalendarItemType.RecurringException => 9, + _ => 99 + }; + } +} diff --git a/Wino.Core.Domain/Interfaces/ICalendarItem.cs b/Wino.Core.Domain/Interfaces/ICalendarItem.cs index e83827a6..e71a481a 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarItem.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarItem.cs @@ -1,5 +1,6 @@ using System; using Itenso.TimePeriod; +using Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Interfaces; @@ -8,15 +9,8 @@ public interface ICalendarItem string Title { get; } Guid Id { get; } IAccountCalendar AssignedCalendar { get; } - DateTime StartDate { get; set; } - DateTime EndDate { get; } - double DurationInSeconds { get; set; } + DateTime StartDateTime { get; set; } + DateTime EndDateTime { get; } ITimePeriod Period { get; } - - bool IsAllDayEvent { get; } - bool IsMultiDayEvent { get; } - - bool IsRecurringChild { get; } - bool IsRecurringParent { get; } - bool IsRecurringEvent { get; } + CalendarItemType ItemType { get; } } diff --git a/Wino.Core.Domain/Interfaces/ICalendarServiceEx.cs b/Wino.Core.Domain/Interfaces/ICalendarServiceEx.cs new file mode 100644 index 00000000..b7ef6856 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ICalendarServiceEx.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Interfaces; +public interface ICalendarServiceEx +{ + Task ClearAllCalendarEventAttendeesAsync(); + Task ClearAllCalendarsAsync(); + Task ClearAllDataAsync(); + Task ClearAllEventsAsync(); + Task DeleteCalendarAsync(string remoteCalendarId); + Task DeleteCalendarEventAttendeesForEventAsync(Guid eventId); + Task DeleteEventAsync(string remoteEventId); + Task> GetAllCalendarEventAttendeesAsync(); + Task> GetAllCalendarsAsync(); + Task> GetAllDayEventsAsync(); + Task> GetAllEventsAsync(); + Task> GetAllEventsIncludingDeletedAsync(); + Task> GetAllRecurringEventsByTypeAsync(); + Task GetCalendarByRemoteIdAsync(string remoteCalendarId); + Task> GetCalendarEventAttendeeResponseCountsAsync(Guid eventId); + Task> GetCalendarEventAttendeesForEventAsync(Guid eventId); + Task> GetCalendarEventAttendeesForEventByRemoteIdAsync(string remoteEventId); + Task GetCalendarSyncTokenAsync(string calendarId); + Task GetEventByRemoteIdAsync(string remoteEventId); + Task> GetEventsByItemTypeAsync(CalendarItemType itemType); + Task> GetEventsByItemTypesAsync(params CalendarItemType[] itemTypes); + Task> GetEventsByremoteCalendarIdAsync(string remoteCalendarId); + Task> GetEventsForCalendarAsync(Guid calendarId); + Task> GetEventsInDateRangeAsync(DateTime startDate, DateTime endDate); + Task> GetEventsSinceLastSyncAsync(DateTime? lastSyncTime); + Task> GetEventStatsByItemTypeAsync(); + Task> GetExpandedEventsInDateRangeAsync(DateTime startDate, DateTime endDate); + Task> GetExpandedEventsInDateRangeWithExceptionsAsync(DateTime startDate, DateTime endDate, AccountCalendar calendar); + Task GetLastSyncTimeAsync(string calendarId); + Task> GetMultiDayEventsAsync(); + Task> GetRecurringEventsAsync(); + Task HardDeleteEventAsync(string remoteEventId); + Task InsertCalendarAsync(AccountCalendar calendar); + Task InsertCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee); + Task InsertEventAsync(CalendarItem calendarItem); + Task MarkEventAsDeletedAsync(string remoteEventId, string remoteCalendarId); + Task SyncAttendeesForEventAsync(Guid eventId, List attendees); + Task SyncCalendarEventAttendeesForEventAsync(Guid eventId, List calendareventattendees); + Task UpdateAllEventItemTypesAsync(); + Task UpdateCalendarAsync(AccountCalendar calendar); + Task UpdateCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee); + Task UpdateCalendarSyncTokenAsync(string calendarId, string syncToken); + Task UpdateEventAsync(CalendarItem calendarItem); + Task UpsertCalendarAsync(AccountCalendar calendar); + Task UpsertEventAsync(CalendarItem calendarItem); +} diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 8e19322a..667444ca 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -5,7 +5,6 @@ using System.Web; using Google.Apis.Calendar.v3.Data; using Google.Apis.Gmail.v1.Data; using MimeKit; -using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -179,6 +178,11 @@ public static class GoogleIntegratorExtensions Id = Guid.NewGuid(), TimeZone = calendarListEntry.TimeZone, IsPrimary = calendarListEntry.Primary.GetValueOrDefault(), + Description = calendarListEntry.Description, + AccessRole = calendarListEntry.AccessRole, + CreatedDate = DateTime.UtcNow, + LastSyncTime = DateTime.UtcNow, + Location = calendarListEntry.Location, }; // Bg color must present. Generate one if doesnt exists. @@ -190,42 +194,121 @@ public static class GoogleIntegratorExtensions return calendar; } - public static DateTimeOffset? GetEventDateTimeOffset(EventDateTime calendarEvent) + public static CalendarItem MapGoogleEventToCalendarEvent(this Event googleEvent, AccountCalendar calendar) { - if (calendarEvent != null) + var calendarEvent = new CalendarItem { - if (calendarEvent.DateTimeDateTimeOffset != null) + RemoteEventId = googleEvent.Id, + CalendarId = calendar.Id, // Use internal Guid + Title = googleEvent.Summary ?? string.Empty, + Description = googleEvent.Description, + Location = googleEvent.Location, + Status = googleEvent.Status, + RecurringEventId = googleEvent.RecurringEventId + }; + + // Handle start and end times + if (googleEvent.Start != null) + { + if (googleEvent.Start.Date != null) { - return calendarEvent.DateTimeDateTimeOffset.Value; + calendarEvent.IsAllDay = true; + calendarEvent.StartDateTime = DateTime.Parse(googleEvent.Start.Date); } - else if (calendarEvent.Date != null) + else if (googleEvent.Start.DateTimeDateTimeOffset.HasValue) { - if (DateTime.TryParse(calendarEvent.Date, out DateTime eventDateTime)) - { - // Date-only events are treated as UTC midnight - return new DateTimeOffset(eventDateTime, TimeSpan.Zero); - } - else - { - throw new Exception("Invalid date format in Google Calendar event date."); - } + calendarEvent.IsAllDay = false; + calendarEvent.StartDateTime = googleEvent.Start.DateTimeDateTimeOffset.Value.DateTime; + calendarEvent.TimeZone = googleEvent.Start.TimeZone; } } - return null; + if (googleEvent.End != null) + { + if (googleEvent.End.Date != null) + { + calendarEvent.EndDateTime = DateTime.Parse(googleEvent.End.Date); + } + else if (googleEvent.End.DateTimeDateTimeOffset.HasValue) + { + calendarEvent.EndDateTime = googleEvent.End.DateTimeDateTimeOffset.Value.DateTime; + } + } + + // Handle recurrence rules + if (googleEvent.Recurrence != null && googleEvent.Recurrence.Count > 0) + { + calendarEvent.RecurrenceRules = string.Join(";", googleEvent.Recurrence); + } + + // Handle organizer + if (googleEvent.Organizer != null) + { + calendarEvent.OrganizerDisplayName = googleEvent.Organizer.DisplayName; + calendarEvent.OrganizerEmail = googleEvent.Organizer.Email; + } + + // Handle timestamps + if (googleEvent.CreatedDateTimeOffset.HasValue) + { + calendarEvent.CreatedDate = googleEvent.CreatedDateTimeOffset.Value.DateTime; + } + + if (googleEvent.UpdatedDateTimeOffset.HasValue) + { + calendarEvent.LastModified = googleEvent.UpdatedDateTimeOffset.Value.DateTime; + } + + // Handle original start time for recurring event instances + if (googleEvent.OriginalStartTime != null) + { + if (googleEvent.OriginalStartTime.Date != null) + { + calendarEvent.OriginalStartTime = googleEvent.OriginalStartTime.Date; + } + else if (googleEvent.OriginalStartTime.DateTimeDateTimeOffset.HasValue) + { + calendarEvent.OriginalStartTime = googleEvent.OriginalStartTime.DateTimeDateTimeOffset.Value.ToString("O"); + } + } + + // Automatically determine the calendar item type based on event properties + calendarEvent.DetermineItemType(); + + return calendarEvent; } /// - /// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545. + /// Converts a Google Calendar API response status string to AttendeeResponseStatus enum /// - /// ___ separated lines. - public static string GetRecurrenceString(this Event calendarEvent) + /// The status string from Google Calendar API + /// Corresponding AttendeeResponseStatus enum value + public static AttendeeResponseStatus FromGoogleStatus(string? googleStatus) { - if (calendarEvent == null || calendarEvent.Recurrence == null || !calendarEvent.Recurrence.Any()) + return googleStatus?.ToLowerInvariant() switch { - return null; - } + "accepted" => AttendeeResponseStatus.Accepted, + "declined" => AttendeeResponseStatus.Declined, + "tentative" => AttendeeResponseStatus.Tentative, + "needsaction" => AttendeeResponseStatus.NeedsAction, + _ => AttendeeResponseStatus.NeedsAction + }; + } - return string.Join(Constants.CalendarEventRecurrenceRuleSeperator, calendarEvent.Recurrence); + /// + /// Converts an AttendeeResponseStatus enum to Google Calendar API response status string + /// + /// The AttendeeResponseStatus enum value + /// Corresponding Google Calendar API status string + public static string ToGoogleStatus(AttendeeResponseStatus status) + { + return status switch + { + AttendeeResponseStatus.Accepted => "accepted", + AttendeeResponseStatus.Declined => "declined", + AttendeeResponseStatus.Tentative => "tentative", + AttendeeResponseStatus.NeedsAction => "needsAction", + _ => "needsAction" + }; } } diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 87051308..c660e911 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using Microsoft.Graph.Models; using MimeKit; using Wino.Core.Domain.Entities.Calendar; @@ -127,125 +126,26 @@ public static class OutlookIntegratorExtensions return calendar; } - private static string GetRfc5545DayOfWeek(DayOfWeekObject dayOfWeek) + + + /// + /// Converts Outlook response status to our enum + /// + /// Outlook response type + /// AttendeeResponseStatus enum value + public static AttendeeResponseStatus ConvertOutlookResponseStatus(this Microsoft.Graph.Models.ResponseType? outlookResponse) { - return dayOfWeek switch + return outlookResponse switch { - DayOfWeekObject.Monday => "MO", - DayOfWeekObject.Tuesday => "TU", - DayOfWeekObject.Wednesday => "WE", - DayOfWeekObject.Thursday => "TH", - DayOfWeekObject.Friday => "FR", - DayOfWeekObject.Saturday => "SA", - DayOfWeekObject.Sunday => "SU", - _ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek), dayOfWeek, null) + Microsoft.Graph.Models.ResponseType.Accepted => AttendeeResponseStatus.Accepted, + Microsoft.Graph.Models.ResponseType.Declined => AttendeeResponseStatus.Declined, + Microsoft.Graph.Models.ResponseType.TentativelyAccepted => AttendeeResponseStatus.Tentative, + Microsoft.Graph.Models.ResponseType.None => AttendeeResponseStatus.NeedsAction, + Microsoft.Graph.Models.ResponseType.NotResponded => AttendeeResponseStatus.NeedsAction, + _ => AttendeeResponseStatus.NeedsAction }; } - public static string ToRfc5545RecurrenceString(this PatternedRecurrence recurrence) - { - if (recurrence == null || recurrence.Pattern == null) - throw new ArgumentNullException(nameof(recurrence), "PatternedRecurrence or its Pattern cannot be null."); - - var ruleBuilder = new StringBuilder("RRULE:"); - var pattern = recurrence.Pattern; - - // Frequency - switch (pattern.Type) - { - case RecurrencePatternType.Daily: - ruleBuilder.Append("FREQ=DAILY;"); - break; - case RecurrencePatternType.Weekly: - ruleBuilder.Append("FREQ=WEEKLY;"); - break; - case RecurrencePatternType.AbsoluteMonthly: - ruleBuilder.Append("FREQ=MONTHLY;"); - break; - case RecurrencePatternType.AbsoluteYearly: - ruleBuilder.Append("FREQ=YEARLY;"); - break; - case RecurrencePatternType.RelativeMonthly: - ruleBuilder.Append("FREQ=MONTHLY;"); - break; - case RecurrencePatternType.RelativeYearly: - ruleBuilder.Append("FREQ=YEARLY;"); - break; - default: - throw new NotSupportedException($"Unsupported recurrence pattern type: {pattern.Type}"); - } - - // Interval - if (pattern.Interval > 0) - ruleBuilder.Append($"INTERVAL={pattern.Interval};"); - - // Days of Week - if (pattern.DaysOfWeek?.Any() == true) - { - var days = string.Join(",", pattern.DaysOfWeek.Select(day => day.ToString().ToUpperInvariant().Substring(0, 2))); - ruleBuilder.Append($"BYDAY={days};"); - } - - // Day of Month (BYMONTHDAY) - if (pattern.Type == RecurrencePatternType.AbsoluteMonthly || pattern.Type == RecurrencePatternType.AbsoluteYearly) - { - if (pattern.DayOfMonth <= 0) - throw new ArgumentException("DayOfMonth must be greater than 0 for absoluteMonthly or absoluteYearly patterns."); - - ruleBuilder.Append($"BYMONTHDAY={pattern.DayOfMonth};"); - } - - // Month (BYMONTH) - if (pattern.Type == RecurrencePatternType.AbsoluteYearly || pattern.Type == RecurrencePatternType.RelativeYearly) - { - if (pattern.Month <= 0) - throw new ArgumentException("Month must be greater than 0 for absoluteYearly or relativeYearly patterns."); - - ruleBuilder.Append($"BYMONTH={pattern.Month};"); - } - - // Count or Until - if (recurrence.Range != null) - { - if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null) - { - ruleBuilder.Append($"UNTIL={recurrence.Range.EndDate.Value:yyyyMMddTHHmmssZ};"); - } - else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue) - { - ruleBuilder.Append($"COUNT={recurrence.Range.NumberOfOccurrences.Value};"); - } - } - - // Remove trailing semicolon - return ruleBuilder.ToString().TrimEnd(';'); - } - - public static DateTimeOffset GetDateTimeOffsetFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone) - { - if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime) || string.IsNullOrEmpty(dateTimeTimeZone.TimeZone)) - { - throw new ArgumentException("DateTimeTimeZone is null or empty."); - } - - try - { - // Parse the DateTime string - if (DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime)) - { - // Get TimeZoneInfo to get the offset - TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(dateTimeTimeZone.TimeZone); - TimeSpan offset = timeZoneInfo.GetUtcOffset(parsedDateTime); - return new DateTimeOffset(parsedDateTime, offset); - } - else - throw new ArgumentException("DateTime string is not in a valid format."); - } - catch (Exception) - { - throw; - } - } private static AttendeeStatus GetAttendeeStatus(ResponseType? responseType) { @@ -261,24 +161,6 @@ public static class OutlookIntegratorExtensions }; } - public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId) - { - bool isOrganizer = attendee?.Status?.Response == ResponseType.Organizer; - - var eventAttendee = new CalendarEventAttendee() - { - CalendarItemId = calendarItemId, - Id = Guid.NewGuid(), - Email = attendee.EmailAddress?.Address, - Name = attendee.EmailAddress?.Name, - AttendenceStatus = GetAttendeeStatus(attendee.Status.Response), - IsOrganizer = isOrganizer, - IsOptionalAttendee = attendee.Type == AttendeeType.Optional, - }; - - return eventAttendee; - } - #region Mime to Outlook Message Helpers private static IEnumerable GetRecipients(this InternetAddressList internetAddresses) diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index a7a98ea3..c008b657 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -21,7 +21,7 @@ namespace Wino.Core.Integration.Processors; /// , and /// None of the synchronizers can directly change anything in the database. /// -public interface IDefaultChangeProcessor +public interface IDefaultChangeProcessor : ICalendarServiceEx { Task UpdateAccountAsync(MailAccount account); // Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier); @@ -113,13 +113,14 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, IMailService mailService, ICalendarService calendarService, IAccountService accountService, - IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor + ICalendarServiceEx calendarServiceEx, + IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor, ICalendarServiceEx { protected IMailService MailService = mailService; protected ICalendarService CalendarService = calendarService; protected IFolderService FolderService = folderService; protected IAccountService AccountService = accountService; - + private readonly ICalendarServiceEx _calendarServiceEx = calendarServiceEx; private readonly IMimeFileService _mimeFileService = mimeFileService; public Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier) @@ -208,4 +209,234 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task IsMailExistsInFolderAsync(string messageId, Guid folderId) => MailService.IsMailExistsAsync(messageId, folderId); + + // TODO: Normalize this shit. Not everything needs to be exposed for processor. + #region ICalendarServiceEx + + public Task ClearAllCalendarEventAttendeesAsync() + { + return _calendarServiceEx.ClearAllCalendarEventAttendeesAsync(); + } + + public Task ClearAllCalendarsAsync() + { + return _calendarServiceEx.ClearAllCalendarsAsync(); + } + + public Task ClearAllDataAsync() + { + return _calendarServiceEx.ClearAllDataAsync(); + } + + public Task ClearAllEventsAsync() + { + return _calendarServiceEx.ClearAllEventsAsync(); + } + + public Task DeleteCalendarAsync(string remoteCalendarId) + { + return _calendarServiceEx.DeleteCalendarAsync(remoteCalendarId); + } + + public Task DeleteCalendarEventAttendeesForEventAsync(Guid eventId) + { + return _calendarServiceEx.DeleteCalendarEventAttendeesForEventAsync(eventId); + } + + public Task DeleteEventAsync(string remoteEventId) + { + return _calendarServiceEx.DeleteEventAsync(remoteEventId); + } + + public Task> GetAllCalendarEventAttendeesAsync() + { + return _calendarServiceEx.GetAllCalendarEventAttendeesAsync(); + } + + public Task> GetAllCalendarsAsync() + { + return _calendarServiceEx.GetAllCalendarsAsync(); + } + + public Task> GetAllDayEventsAsync() + { + return _calendarServiceEx.GetAllDayEventsAsync(); + } + + public Task> GetAllEventsAsync() + { + return _calendarServiceEx.GetAllEventsAsync(); + } + + public Task> GetAllEventsIncludingDeletedAsync() + { + return _calendarServiceEx.GetAllEventsIncludingDeletedAsync(); + } + + public Task> GetAllRecurringEventsByTypeAsync() + { + return _calendarServiceEx.GetAllRecurringEventsByTypeAsync(); + } + + public Task GetCalendarByRemoteIdAsync(string remoteCalendarId) + { + return _calendarServiceEx.GetCalendarByRemoteIdAsync(remoteCalendarId); + } + + public Task> GetCalendarEventAttendeeResponseCountsAsync(Guid eventId) + { + return _calendarServiceEx.GetCalendarEventAttendeeResponseCountsAsync(eventId); + } + + public Task> GetCalendarEventAttendeesForEventAsync(Guid eventId) + { + return _calendarServiceEx.GetCalendarEventAttendeesForEventAsync(eventId); + } + + public Task> GetCalendarEventAttendeesForEventByRemoteIdAsync(string remoteEventId) + { + return _calendarServiceEx.GetCalendarEventAttendeesForEventByRemoteIdAsync(remoteEventId); + } + + public Task GetCalendarSyncTokenAsync(string calendarId) + { + return _calendarServiceEx.GetCalendarSyncTokenAsync(calendarId); + } + + public Task GetEventByRemoteIdAsync(string remoteEventId) + { + return _calendarServiceEx.GetEventByRemoteIdAsync(remoteEventId); + } + + public Task> GetEventsByItemTypeAsync(CalendarItemType itemType) + { + return _calendarServiceEx.GetEventsByItemTypeAsync(itemType); + } + + public Task> GetEventsByItemTypesAsync(params CalendarItemType[] itemTypes) + { + return _calendarServiceEx.GetEventsByItemTypesAsync(itemTypes); + } + + public Task> GetEventsByremoteCalendarIdAsync(string remoteCalendarId) + { + return _calendarServiceEx.GetEventsByremoteCalendarIdAsync(remoteCalendarId); + } + + public Task> GetEventsForCalendarAsync(Guid calendarId) + { + return _calendarServiceEx.GetEventsForCalendarAsync(calendarId); + } + + public Task> GetEventsInDateRangeAsync(DateTime startDate, DateTime endDate) + { + return _calendarServiceEx.GetEventsInDateRangeAsync(startDate, endDate); + } + + public Task> GetEventsSinceLastSyncAsync(DateTime? lastSyncTime) + { + return _calendarServiceEx.GetEventsSinceLastSyncAsync(lastSyncTime); + } + + public Task> GetEventStatsByItemTypeAsync() + { + return _calendarServiceEx.GetEventStatsByItemTypeAsync(); + } + + public Task> GetExpandedEventsInDateRangeAsync(DateTime startDate, DateTime endDate) + { + return _calendarServiceEx.GetExpandedEventsInDateRangeAsync(startDate, endDate); + } + + public Task> GetExpandedEventsInDateRangeWithExceptionsAsync(DateTime startDate, DateTime endDate, AccountCalendar calendar) + { + return _calendarServiceEx.GetExpandedEventsInDateRangeWithExceptionsAsync(startDate, endDate, calendar); + } + + public Task GetLastSyncTimeAsync(string calendarId) + { + return _calendarServiceEx.GetLastSyncTimeAsync(calendarId); + } + + public Task> GetMultiDayEventsAsync() + { + return _calendarServiceEx.GetMultiDayEventsAsync(); + } + + public Task> GetRecurringEventsAsync() + { + return _calendarServiceEx.GetRecurringEventsAsync(); + } + + public Task HardDeleteEventAsync(string remoteEventId) + { + return _calendarServiceEx.HardDeleteEventAsync(remoteEventId); + } + + public Task InsertCalendarAsync(AccountCalendar calendar) + { + return _calendarServiceEx.InsertCalendarAsync(calendar); + } + + public Task InsertCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee) + { + return _calendarServiceEx.InsertCalendarEventAttendeeAsync(calendareventattendee); + } + + public Task InsertEventAsync(CalendarItem calendarItem) + { + return _calendarServiceEx.InsertEventAsync(calendarItem); + } + + public Task MarkEventAsDeletedAsync(string remoteEventId, string remoteCalendarId) + { + return _calendarServiceEx.MarkEventAsDeletedAsync(remoteEventId, remoteCalendarId); + } + + public Task SyncCalendarEventAttendeesForEventAsync(Guid eventId, List calendareventattendees) + { + return _calendarServiceEx.SyncCalendarEventAttendeesForEventAsync(eventId, calendareventattendees); + } + + public Task UpdateAllEventItemTypesAsync() + { + return _calendarServiceEx.UpdateAllEventItemTypesAsync(); + } + + public Task UpdateCalendarAsync(AccountCalendar calendar) + { + return _calendarServiceEx.UpdateCalendarAsync(calendar); + } + + public Task UpdateCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee) + { + return _calendarServiceEx.UpdateCalendarEventAttendeeAsync(calendareventattendee); + } + + public Task UpdateCalendarSyncTokenAsync(string calendarId, string syncToken) + { + return _calendarServiceEx.UpdateCalendarSyncTokenAsync(calendarId, syncToken); + } + + public Task UpdateEventAsync(CalendarItem calendarItem) + { + return _calendarServiceEx.UpdateEventAsync(calendarItem); + } + + public Task UpsertCalendarAsync(AccountCalendar calendar) + { + return _calendarServiceEx.UpsertCalendarAsync(calendar); + } + + public Task UpsertEventAsync(CalendarItem calendarItem) + { + return _calendarServiceEx.UpsertEventAsync(calendarItem); + } + + public Task SyncAttendeesForEventAsync(Guid eventId, List attendees) + { + return _calendarServiceEx.SyncAttendeesForEventAsync(eventId, attendees); + } + + #endregion } diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index c478308d..ce753375 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -1,18 +1,13 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; using Google.Apis.Calendar.v3.Data; -using Serilog; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; -using Wino.Core.Extensions; using Wino.Services; -using CalendarEventAttendee = Wino.Core.Domain.Entities.Calendar.CalendarEventAttendee; -using CalendarItem = Wino.Core.Domain.Entities.Calendar.CalendarItem; namespace Wino.Core.Integration.Processors; @@ -23,7 +18,8 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso IMailService mailService, ICalendarService calendarService, IAccountService accountService, - IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) + ICalendarServiceEx calendarServiceEx, + IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, calendarServiceEx, mimeFileService) { } @@ -36,224 +32,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount) { - var status = calendarEvent.Status; - - var recurringEventId = calendarEvent.RecurringEventId; - - // 1. Canceled exceptions of recurred events are only guaranteed to have recurringEventId, Id and start time. - // 2. Updated exceptions of recurred events have different Id, but recurringEventId is the same as parent. - - // Check if we have this event before. - var existingCalendarItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id); - - if (existingCalendarItem == null) - { - CalendarItem parentRecurringEvent = null; - - // Manage the recurring event id. - if (!string.IsNullOrEmpty(recurringEventId)) - { - parentRecurringEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, recurringEventId).ConfigureAwait(false); - - if (parentRecurringEvent == null) - { - Log.Information($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}"); - return; - } - } - - // We don't have this event yet. Create a new one. - var eventStartDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.Start); - var eventEndDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.End); - - double totalDurationInSeconds = 0; - - if (eventStartDateTimeOffset != null && eventEndDateTimeOffset != null) - { - totalDurationInSeconds = (eventEndDateTimeOffset.Value - eventStartDateTimeOffset.Value).TotalSeconds; - } - - CalendarItem calendarItem = null; - - if (parentRecurringEvent != null) - { - // Exceptions of parent events might not have all the fields populated. - // We must use the parent event's data for fields that don't exists. - - // Update duration if it's not populated. - if (totalDurationInSeconds == 0) - { - totalDurationInSeconds = parentRecurringEvent.DurationInSeconds; - } - - var organizerMail = GetOrganizerEmail(calendarEvent, organizerAccount); - var organizerName = GetOrganizerName(calendarEvent, organizerAccount); - - - calendarItem = new CalendarItem() - { - CalendarId = assignedCalendar.Id, - CreatedAt = DateTimeOffset.UtcNow, - Description = calendarEvent.Description ?? parentRecurringEvent.Description, - Id = Guid.NewGuid(), - StartDate = eventStartDateTimeOffset.Value.DateTime, - StartDateOffset = eventStartDateTimeOffset.Value.Offset, - EndDateOffset = eventEndDateTimeOffset?.Offset ?? parentRecurringEvent.EndDateOffset, - DurationInSeconds = totalDurationInSeconds, - Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location, - - // Leave it empty if it's not populated. - Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent) == null ? string.Empty : GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent), - Status = GetStatus(calendarEvent.Status), - Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary, - UpdatedAt = DateTimeOffset.UtcNow, - Visibility = string.IsNullOrEmpty(calendarEvent.Visibility) ? parentRecurringEvent.Visibility : GetVisibility(calendarEvent.Visibility), - HtmlLink = string.IsNullOrEmpty(calendarEvent.HtmlLink) ? parentRecurringEvent.HtmlLink : calendarEvent.HtmlLink, - RemoteEventId = calendarEvent.Id, - IsLocked = calendarEvent.Locked.GetValueOrDefault(), - OrganizerDisplayName = string.IsNullOrEmpty(organizerName) ? parentRecurringEvent.OrganizerDisplayName : organizerName, - OrganizerEmail = string.IsNullOrEmpty(organizerMail) ? parentRecurringEvent.OrganizerEmail : organizerMail - }; - } - else - { - // This is a parent event creation. - // Start-End dates are guaranteed to be populated. - - if (eventStartDateTimeOffset == null || eventEndDateTimeOffset == null) - { - Log.Error("Failed to create parent event because either start or end date is not specified."); - return; - } - - calendarItem = new CalendarItem() - { - CalendarId = assignedCalendar.Id, - CreatedAt = DateTimeOffset.UtcNow, - Description = calendarEvent.Description, - Id = Guid.NewGuid(), - StartDate = eventStartDateTimeOffset.Value.DateTime, - StartDateOffset = eventStartDateTimeOffset.Value.Offset, - EndDateOffset = eventEndDateTimeOffset.Value.Offset, - DurationInSeconds = totalDurationInSeconds, - Location = calendarEvent.Location, - Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent), - Status = GetStatus(calendarEvent.Status), - Title = calendarEvent.Summary, - UpdatedAt = DateTimeOffset.UtcNow, - Visibility = GetVisibility(calendarEvent.Visibility), - HtmlLink = calendarEvent.HtmlLink, - RemoteEventId = calendarEvent.Id, - IsLocked = calendarEvent.Locked.GetValueOrDefault(), - OrganizerDisplayName = GetOrganizerName(calendarEvent, organizerAccount), - OrganizerEmail = GetOrganizerEmail(calendarEvent, organizerAccount) - }; - } - - // Hide canceled events. - calendarItem.IsHidden = calendarItem.Status == CalendarItemStatus.Cancelled; - - // Manage the recurring event id. - if (parentRecurringEvent != null) - { - calendarItem.RecurringCalendarItemId = parentRecurringEvent.Id; - } - - Debug.WriteLine($"({assignedCalendar.Name}) {calendarItem.Title}, Start: {calendarItem.StartDate.ToString("f")}, End: {calendarItem.EndDate.ToString("f")}"); - - // Attendees - var attendees = new List(); - - if (calendarEvent.Attendees == null) - { - // Self-only event. - - attendees.Add(new CalendarEventAttendee() - { - CalendarItemId = calendarItem.Id, - IsOrganizer = true, - Email = organizerAccount.Address, - Name = organizerAccount.SenderName, - AttendenceStatus = AttendeeStatus.Accepted, - Id = Guid.NewGuid(), - IsOptionalAttendee = false, - }); - } - else - { - foreach (var attendee in calendarEvent.Attendees) - { - if (attendee.Self == true) - { - // TODO: - } - else if (!string.IsNullOrEmpty(attendee.Email)) - { - AttendeeStatus GetAttendenceStatus(string responseStatus) - { - return responseStatus switch - { - "accepted" => AttendeeStatus.Accepted, - "declined" => AttendeeStatus.Declined, - "tentative" => AttendeeStatus.Tentative, - "needsAction" => AttendeeStatus.NeedsAction, - _ => AttendeeStatus.NeedsAction - }; - } - - var eventAttendee = new CalendarEventAttendee() - { - CalendarItemId = calendarItem.Id, - IsOrganizer = attendee.Organizer ?? false, - Comment = attendee.Comment, - Email = attendee.Email, - Name = attendee.DisplayName, - AttendenceStatus = GetAttendenceStatus(attendee.ResponseStatus), - Id = Guid.NewGuid(), - IsOptionalAttendee = attendee.Optional ?? false, - }; - - attendees.Add(eventAttendee); - } - } - } - - await CalendarService.CreateNewCalendarItemAsync(calendarItem, attendees); - } - else - { - // We have this event already. Update it. - if (calendarEvent.Status == "cancelled") - { - // Parent event is canceled. We must delete everything. - if (string.IsNullOrEmpty(recurringEventId)) - { - Log.Information("Parent event is canceled. Deleting all instances of {Id}", existingCalendarItem.Id); - - await CalendarService.DeleteCalendarItemAsync(existingCalendarItem.Id).ConfigureAwait(false); - - return; - } - else - { - // Child event is canceled. - // Child should live as long as parent lives, but must not be displayed to the user. - - existingCalendarItem.IsHidden = true; - } - } - else - { - // Make sure to unhide the event. - // It might be marked as hidden before. - existingCalendarItem.IsHidden = false; - - // Update the event properties. - } - } - - // Upsert the event. - await Connection.InsertOrReplaceAsync(existingCalendarItem); + // TODO: } private string GetOrganizerName(Event calendarEvent, MailAccount account) diff --git a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs index f23470bf..6d84ebee 100644 --- a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs @@ -13,7 +13,8 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor IMailService mailService, IAccountService accountService, ICalendarService calendarService, - IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) + ICalendarServiceEx calendarServiceEx, + IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, calendarServiceEx, mimeFileService) { } diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 3b72c33e..d539e80b 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -1,13 +1,9 @@ using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.Graph.Models; -using Serilog; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; -using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; -using Wino.Core.Extensions; using Wino.Services; namespace Wino.Core.Integration.Processors; @@ -17,7 +13,8 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, ICalendarService calendarService, IMailService mailService, IAccountService accountService, - IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) + ICalendarServiceEx calendarServiceEx, + IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, calendarService, accountService, calendarServiceEx, mimeFileService) , IOutlookChangeProcessor { @@ -40,106 +37,6 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount) { - // We parse the occurrences based on the parent event. - // There is literally no point to store them because - // type=Exception events are the exceptional childs of recurrency parent event. - - if (calendarEvent.Type == EventType.Occurrence) return; - - var savingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id); - - Guid savingItemId = Guid.Empty; - - if (savingItem != null) - savingItemId = savingItem.Id; - else - { - savingItemId = Guid.NewGuid(); - savingItem = new CalendarItem() { Id = savingItemId }; - } - - DateTimeOffset eventStartDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.Start); - DateTimeOffset eventEndDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.End); - - var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds; - - savingItem.RemoteEventId = calendarEvent.Id; - savingItem.StartDate = eventStartDateTimeOffset.DateTime; - savingItem.StartDateOffset = eventStartDateTimeOffset.Offset; - savingItem.EndDateOffset = eventEndDateTimeOffset.Offset; - savingItem.DurationInSeconds = durationInSeconds; - - savingItem.Title = calendarEvent.Subject; - savingItem.Description = calendarEvent.Body?.Content; - savingItem.Location = calendarEvent.Location?.DisplayName; - - if (calendarEvent.Type == EventType.Exception && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId)) - { - // This is a recurring event exception. - // We need to find the parent event and set it as recurring event id. - - var parentEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterId); - - if (parentEvent != null) - { - savingItem.RecurringCalendarItemId = parentEvent.Id; - } - else - { - Log.Warning($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}"); - return; - } - } - - // Convert the recurrence pattern to string for parent recurring events. - if (calendarEvent.Type == EventType.SeriesMaster && calendarEvent.Recurrence != null) - { - savingItem.Recurrence = OutlookIntegratorExtensions.ToRfc5545RecurrenceString(calendarEvent.Recurrence); - } - - savingItem.HtmlLink = calendarEvent.WebLink; - savingItem.CalendarId = assignedCalendar.Id; - savingItem.OrganizerEmail = calendarEvent.Organizer?.EmailAddress?.Address; - savingItem.OrganizerDisplayName = calendarEvent.Organizer?.EmailAddress?.Name; - savingItem.IsHidden = false; - - if (calendarEvent.ResponseStatus?.Response != null) - { - switch (calendarEvent.ResponseStatus.Response.Value) - { - case ResponseType.None: - case ResponseType.NotResponded: - savingItem.Status = CalendarItemStatus.NotResponded; - break; - case ResponseType.TentativelyAccepted: - savingItem.Status = CalendarItemStatus.Tentative; - break; - case ResponseType.Accepted: - case ResponseType.Organizer: - savingItem.Status = CalendarItemStatus.Confirmed; - break; - case ResponseType.Declined: - savingItem.Status = CalendarItemStatus.Cancelled; - savingItem.IsHidden = true; - break; - default: - break; - } - } - else - { - savingItem.Status = CalendarItemStatus.Confirmed; - } - - // Upsert the event. - await Connection.InsertOrReplaceAsync(savingItem); - - // Manage attendees. - if (calendarEvent.Attendees != null) - { - // Clear all attendees for this event. - var attendees = calendarEvent.Attendees.Select(a => a.CreateAttendee(savingItemId)).ToList(); - await CalendarService.ManageEventAttendeesAsync(savingItemId, attendees).ConfigureAwait(false); - } + // TODO } } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index e1fb9b49..b69f7bd1 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -361,75 +361,207 @@ public class GmailSynchronizer : WinoSynchronizer(), + }; + } - if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken)) + private async Task FullSynchronizeCalendarAsync(AccountCalendar calendar) + { + var calendarId = calendar.RemoteCalendarId; + + try + { + // Get events from the last 30 days to 1 year in the future + var timeMin = DateTime.Now.AddYears(-3); + var timeMax = DateTime.Now.AddYears(2); + + var request = _calendarService.Events.List(calendarId); + request.TimeMinDateTimeOffset = timeMin; + request.TimeMaxDateTimeOffset = timeMax; + request.SingleEvents = false; // Include recurring events + request.ShowDeleted = true; // Include deleted events for synchronization + request.MaxResults = 2500; // Maximum allowed by Google Calendar API + + var events = await request.ExecuteAsync(); + + if (events.Items != null && events.Items.Count > 0) { - // If a sync token is available, perform an incremental sync - request.SyncToken = calendar.SynchronizationDeltaToken; + Console.WriteLine($"Processing {events.Items.Count} events from calendar: {calendarId}"); + + foreach (var googleEvent in events.Items) + { + await ProcessGoogleEventAsync(googleEvent, calendar); + } } else { - // If no sync token, perform an initial sync - // Fetch events from the past year - - request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1); + Console.WriteLine($"No events found in calendar: {calendarId}"); } - string nextPageToken; - string syncToken; - - var allEvents = new List(); - - do + // Store the sync token for future delta syncs + if (!string.IsNullOrEmpty(events.NextSyncToken)) { - // Execute the request - var events = await request.ExecuteAsync(); + await _gmailChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, events.NextSyncToken).ConfigureAwait(false); - // Process the fetched events - if (events.Items != null) - { - allEvents.AddRange(events.Items); - } - - // Get the next page token and sync token - nextPageToken = events.NextPageToken; - syncToken = events.NextSyncToken; - - // Set the next page token for subsequent requests - request.PageToken = nextPageToken; - - } while (!string.IsNullOrEmpty(nextPageToken)); - - calendar.SynchronizationDeltaToken = syncToken; - - // allEvents contains new or updated events. - // Process them and create/update local calendar items. - - foreach (var @event in allEvents) - { - // TODO: Exception handling for event processing. - // TODO: Also update attendees and other properties. - - await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false); + Console.WriteLine($"Stored sync token for calendar {calendarId} to enable delta sync"); } - - await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false); } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to synchronize calendar {calendarId}: {ex.Message}", ex); + } + } - return default; + private async Task DeltaSynchronizeCalendarAsync(AccountCalendar calendar) + { + var calendarId = calendar.RemoteCalendarId; + + try + { + Console.WriteLine($"Starting delta sync for calendar: {calendarId}"); + + // Get the stored sync token for this calendar + var syncToken = calendar.SynchronizationDeltaToken; + + if (string.IsNullOrEmpty(syncToken)) + { + Console.WriteLine($"No sync token found for calendar {calendarId}. Performing full sync..."); + await FullSynchronizeCalendarAsync(calendar); + return true; + } + + // Create the events list request with sync token + var request = _calendarService.Events.List(calendarId); + request.SyncToken = syncToken; + request.ShowDeleted = true; // Important: include deleted events for delta sync + request.SingleEvents = false; // Include recurring events + + Console.WriteLine($"Requesting delta changes with sync token: {syncToken.Substring(0, Math.Min(20, syncToken.Length))}..."); + + Events events; + try + { + events = await request.ExecuteAsync(); + } + catch (Google.GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Gone) + { + // Sync token has expired, need to do full sync + Console.WriteLine($"Sync token expired for calendar {calendarId}. Performing full sync..."); + await FullSynchronizeCalendarAsync(calendar); + return true; + } + + if (events.Items != null && events.Items.Count > 0) + { + Console.WriteLine($"Processing {events.Items.Count} delta changes for calendar: {calendarId}"); + + foreach (var googleEvent in events.Items) + { + await ProcessDeltaCalendarEventAsync(googleEvent, calendar); + } + } + else + { + Console.WriteLine($"No changes found for calendar: {calendarId}"); + } + + // Store the new sync token + if (!string.IsNullOrEmpty(events.NextSyncToken)) + { + await _gmailChangeProcessor.UpdateCalendarSyncTokenAsync(calendarId, events.NextSyncToken).ConfigureAwait(false); + + calendar.SynchronizationDeltaToken = events.NextSyncToken; + Console.WriteLine($"Updated sync token for calendar {calendarId}"); + } + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error during delta sync for calendar {calendarId}: {ex.Message}"); + return false; + } + } + + /// + /// Processes a single event change from delta synchronization + /// + /// The Google Calendar event + /// The ID of the calendar containing the event + private async Task ProcessDeltaCalendarEventAsync(Event googleEvent, AccountCalendar calendar) + { + var calendarId = calendar.RemoteCalendarId; + + try + { + if (googleEvent.Status == "cancelled") + { + // Handle deleted/canceled events + await _gmailChangeProcessor.MarkEventAsDeletedAsync(googleEvent.Id, calendarId); + Console.WriteLine($"🗑️ Marked event as deleted: {googleEvent.Summary ?? googleEvent.Id}"); + return; + } + + // For active events (confirmed, tentative), process normally + var calendarEvent = GoogleIntegratorExtensions.MapGoogleEventToCalendarEvent(googleEvent, calendar); + var result = await _gmailChangeProcessor.UpsertEventAsync(calendarEvent); + + // Sync attendees for delta events too + await SyncEventAttendeesAsync(googleEvent, calendarEvent.Id); + + if (result > 0) + { + var action = await _gmailChangeProcessor.GetEventByRemoteIdAsync(googleEvent.Id) != null ? "Updated" : "Created"; + Console.WriteLine($"✅ {action} event: {calendarEvent.Title} ({calendarEvent.RemoteEventId})"); + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to process delta event {googleEvent.Id}: {ex.Message}"); + } + } + + /// + /// Processes a single Google Calendar event and updates the local database + /// + /// The Google Calendar event + /// The ID of the calendar containing the event + private async Task ProcessGoogleEventAsync(Event googleEvent, AccountCalendar calendar) + { + try + { + if (googleEvent.Status == "cancelled") + { + // Handle deleted events + await _gmailChangeProcessor.DeleteEventAsync(googleEvent.Id); + Console.WriteLine($"Marked event as deleted: {googleEvent.Summary ?? googleEvent.Id}"); + return; + } + + var calendarEvent = googleEvent.MapGoogleEventToCalendarEvent(calendar); + await _gmailChangeProcessor.UpsertEventAsync(calendarEvent); + + // Sync attendees separately + await SyncEventAttendeesAsync(googleEvent, calendarEvent.Id); + + Console.WriteLine($"Processed event: {calendarEvent.Title} ({calendarEvent.RemoteEventId})"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to process event {googleEvent.Id}: {ex.Message}"); + } } private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default) @@ -511,6 +643,40 @@ public class GmailSynchronizer : WinoSynchronizer + /// Syncs attendees for an event from Google Calendar data + /// + /// The Google Calendar event + /// The internal event Guid + private async Task SyncEventAttendeesAsync(Event googleEvent, Guid eventId) + { + var attendees = new List(); + + if (googleEvent.Attendees != null && googleEvent.Attendees.Count > 0) + { + foreach (var googleAttendee in googleEvent.Attendees) + { + var attendee = new CalendarEventAttendee + { + EventId = eventId, + Email = googleAttendee.Email ?? string.Empty, + DisplayName = googleAttendee.DisplayName, + ResponseStatus = GoogleIntegratorExtensions.FromGoogleStatus(googleAttendee.ResponseStatus), + IsOptional = googleAttendee.Optional ?? false, + IsOrganizer = googleAttendee.Organizer ?? false, + IsSelf = googleAttendee.Self ?? false, + Comment = googleAttendee.Comment, + AdditionalGuests = googleAttendee.AdditionalGuests + }; + + attendees.Add(attendee); + } + } + + // Sync attendees (replaces existing) + await _gmailChangeProcessor.SyncAttendeesForEventAsync(eventId, attendees).ConfigureAwait(false); + } + private async Task InitializeArchiveFolderAsync() { var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index a2ab4bd9..48508566 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1170,120 +1170,982 @@ public class OutlookSynchronizer : WinoSynchronizer - { - requestConfiguration.QueryParameters.StartDateTime = startDate; - requestConfiguration.QueryParameters.EndDateTime = endDate; - }, cancellationToken: cancellationToken); - - // No delta link. Performing initial sync. - //eventsDeltaResponse = await _graphClient.Me.CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) => - //{ - // requestConfiguration.QueryParameters.StartDateTime = startDate; - // requestConfiguration.QueryParameters.EndDateTime = endDate; - - // // TODO: Expand does not work. - // // https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/2358 - - // requestConfiguration.QueryParameters.Expand = new string[] { "calendar($select=name,id)" }; // Expand the calendar and select name and id. Customize as needed. - //}, cancellationToken: cancellationToken); + await FullSynchronizeCalendarEventsAsync(calendar); } else { - var currentDeltaToken = calendar.SynchronizationDeltaToken; - - _logger.Information("Performing delta sync for calendar {Name}.", calendar.Name); - - var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation((requestConfiguration) => - { - - //requestConfiguration.QueryParameters.StartDateTime = startDate; - //requestConfiguration.QueryParameters.EndDateTime = endDate; - }); - - //var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation((config) => - //{ - // config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder; - // config.QueryParameters.Select = outlookMessageSelectParameters; - // config.QueryParameters.Orderby = ["receivedDateTime desc"]; - //}); - - - requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); - requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); - - eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); - } - - List events = new(); - - // We must first save the parent recurring events to not lose exceptions. - // Therefore, order the existing items by their type and save the parent recurring events first. - - var messageIteratorAsync = PageIterator.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) => - { - events.Add(item); - - return true; - }); - - await messageIteratorAsync - .IterateAsync(cancellationToken) - .ConfigureAwait(false); - - // Desc-order will move parent recurring events to the top. - events = events.OrderByDescending(a => a.Type).ToList(); - - _logger.Information("Found {Count} events in total.", events.Count); - - foreach (var item in events) - { - try - { - await _handleItemRetrievalSemaphore.WaitAsync(); - await _outlookChangeProcessor.ManageCalendarEventAsync(item, calendar, Account).ConfigureAwait(false); - } - catch (Exception) - { - // _logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name); - } - finally - { - _handleItemRetrievalSemaphore.Release(); - } - } - - var latestDeltaLink = messageIteratorAsync.Deltalink; - - //Store delta link for tracking new changes. - if (!string.IsNullOrEmpty(latestDeltaLink)) - { - // Parse Delta Token from Delta Link since v5 of Graph SDK works based on the token, not the link. - - var deltaToken = GetDeltaTokenFromDeltaLink(latestDeltaLink); - - await _outlookChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).ConfigureAwait(false); + await DeltaSynchronizeCalendarAsync(calendar); } } return default; } + /// + /// Checks if the token is a time-based token (old format) rather than a delta token + /// + /// The token to check + /// True if it's a time-based token + private bool IsTimeBasedToken(string token) + { + // Time-based tokens are ISO 8601 datetime strings + return DateTime.TryParse(token, out _); + } + + /// + /// Executes a delta query using the provided delta URL + /// + /// The delta URL from previous sync + /// Event collection response + private async Task ExecuteDeltaQueryAsync(string deltaUrl) + { + try + { + // Create a custom request using the delta URL + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(deltaUrl) + }; + + // Add required headers + requestInfo.Headers.Add("Accept", "application/json"); + + var response = await _graphClient.RequestAdapter.SendAsync(requestInfo, EventCollectionResponse.CreateFromDiscriminatorValue); + return response; + } + catch (Exception ex) + { + Console.WriteLine($"Error executing delta query: {ex.Message}"); + throw; + } + } + + /// + /// Extracts the delta token from the @odata.deltaLink property in the response + /// + /// The event collection response + /// The delta token URL or null if not found + private string? ExtractDeltaTokenFromResponse(EventCollectionResponse? response) + { + try + { + if (response?.AdditionalData?.ContainsKey("@odata.deltaLink") == true) + { + return response.AdditionalData["@odata.deltaLink"]?.ToString(); + } + + // Check for nextLink first, then deltaLink + if (response?.OdataNextLink != null) + { + return response.OdataNextLink; + } + + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Error extracting delta token: {ex.Message}"); + return null; + } + } + + /// + /// Processes pagination for delta events and continues until deltaLink is reached + /// + /// The database calendar ID + /// The initial delta response + private async Task ProcessDeltaEventsPaginationAsync(Guid calendarId, EventCollectionResponse? initialResponse, string? outlookCalendarId = null) + { + try + { + var currentResponse = initialResponse; + + while (!string.IsNullOrEmpty(currentResponse?.OdataNextLink)) + { + Console.WriteLine($" 📃 Processing next page of delta events..."); + + // Get next page + currentResponse = await ExecuteDeltaQueryAsync(currentResponse.OdataNextLink); + + var events = currentResponse?.Value ?? new List(); + + foreach (var outlookEvent in events) + { + await ProcessOutlookDeltaEventAsync(calendarId, outlookEvent, outlookCalendarId); + } + } + + Console.WriteLine($" ✅ Completed processing all delta event pages"); + + // Update the delta token from the final response + if (currentResponse != null) + { + var finalDeltaToken = ExtractDeltaTokenFromResponse(currentResponse); + if (!string.IsNullOrEmpty(finalDeltaToken) && !string.IsNullOrEmpty(outlookCalendarId)) + { + var calendarRemoteId = $"{outlookCalendarId}"; + await _outlookChangeProcessor.UpdateCalendarSyncTokenAsync(calendarRemoteId, finalDeltaToken); + Console.WriteLine($" 🔄 Updated delta token for next sync"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error processing delta events pagination: {ex.Message}"); + throw; + } + } + + /// + /// Extracts delta token from delta initialization response + /// + /// The delta response + /// The delta token or null + private string? ExtractDeltaTokenFromInitResponse(Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse? response) + { + try + { + Console.WriteLine($" 🔍 Extracting delta token from init response..."); + + if (!string.IsNullOrEmpty(response?.OdataDeltaLink)) + { + return response?.OdataDeltaLink; + } + + if (response?.AdditionalData?.ContainsKey("@odata.deltaLink") == true) + { + var deltaLink = response.AdditionalData["@odata.deltaLink"]?.ToString(); + Console.WriteLine($" 📄 Found @odata.deltaLink: {deltaLink}"); + return deltaLink; + } + + if (response?.OdataNextLink != null) + { + Console.WriteLine($" 📄 Found @odata.nextLink: {response.OdataNextLink}"); + return response.OdataNextLink; + } + + Console.WriteLine($" ⚠️ No delta or next link found in response"); + if (response?.AdditionalData != null) + { + Console.WriteLine($" 📋 Available additional data keys: {string.Join(", ", response.AdditionalData.Keys)}"); + } + + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Error extracting delta token from init response: {ex.Message}"); + return null; + } + } + + /// + /// Processes pagination during delta initialization + /// + /// The database calendar ID + /// The initial delta response + /// The Outlook calendar ID + private async Task ProcessDeltaInitializationPaginationAsync(Guid calendarId, Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse? initialResponse, string outlookCalendarId) + { + try + { + if (initialResponse == null) + { + Console.WriteLine($" ⚠️ No initial response for pagination"); + return; + } + + Console.WriteLine($" 🔄 Processing pagination for delta initialization..."); + + // Process all events through pagination + var currentResponse = initialResponse; + + // Process initial page events + if (currentResponse.Value != null) + { + foreach (var outlookEvent in currentResponse.Value) + { + await SynchronizeEventAsync(calendarId, outlookEvent); + } + } + + // Continue pagination if there are more pages + while (!string.IsNullOrEmpty(currentResponse?.OdataNextLink)) + { + Console.WriteLine($" 📃 Processing next page of initialization events..."); + + // Create a request for the next page URL + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(currentResponse.OdataNextLink) + }; + requestInfo.Headers.Add("Accept", "application/json"); + + // Get next page as DeltaGetResponse + currentResponse = await _graphClient.RequestAdapter.SendAsync(requestInfo, Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); + + // Process events from this page + if (currentResponse?.Value != null) + { + foreach (var outlookEvent in currentResponse.Value) + { + await SynchronizeEventAsync(calendarId, outlookEvent); + } + } + } + + // Now extract delta token from the FINAL response (after all pagination) + var deltaToken = ExtractDeltaTokenFromInitResponse(currentResponse); + if (!string.IsNullOrEmpty(deltaToken)) + { + await _outlookChangeProcessor.UpdateCalendarSyncTokenAsync($"{outlookCalendarId}", deltaToken); + Console.WriteLine($" 🎯 Delta token established for future incremental syncs: {deltaToken?.Substring(0, Math.Min(50, deltaToken.Length))}..."); + } + else + { + Console.WriteLine($" ⚠️ No delta token received - will retry on next sync"); + } + + Console.WriteLine(" ✅ Completed processing all initialization pages"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error processing delta initialization pagination: {ex.Message}"); + throw; + } + } + + /// + /// Initializes an Outlook calendar with full sync and establishes delta token + /// + /// The Outlook calendar ID to initialize + private async Task InitializeOutlookCalendarWithDeltaTokenAsync(string outlookCalendarId) + { + try + { + Console.WriteLine($" 🔄 Initializing delta sync for calendar: {outlookCalendarId}"); + + // Get the database calendar + var dbCalendar = await _outlookChangeProcessor.GetCalendarByRemoteIdAsync($"{outlookCalendarId}"); + if (dbCalendar == null) + { + Console.WriteLine($" ❌ Database calendar not found: {outlookCalendarId}"); + return; + } + + // Perform initial delta query to get baseline and delta token + // Use custom request to avoid problematic query parameters + Console.WriteLine($" 🔍 Making clean delta request..."); + + // Build a clean delta URL without problematic query parameters + var deltaUrl = $"https://graph.microsoft.com/v1.0/me/calendars/{outlookCalendarId}/events/delta"; + Console.WriteLine($" 🔍 Clean delta request URL: {deltaUrl}"); + + // Execute clean delta request + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(deltaUrl) + }; + requestInfo.Headers.Add("Accept", "application/json"); + + var initialDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInfo, Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); + + var allEvents = initialDeltaResponse?.Value ?? new List(); + + if (allEvents.Count > 0) + { + Console.WriteLine($" 📥 Processing {allEvents.Count} events during initialization..."); + + foreach (var outlookEvent in allEvents) + { + await SynchronizeEventAsync(dbCalendar.Id, outlookEvent); + } + } + + // Process all pages to get to the deltaLink + await ProcessDeltaInitializationPaginationAsync(dbCalendar.Id, initialDeltaResponse, outlookCalendarId); + + Console.WriteLine($" 🎯 Delta token initialization completed"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Failed to initialize delta sync for calendar {outlookCalendarId}: {ex.Message}"); + throw; + } + } + + private async Task FullSynchronizeCalendarEventsAsync(AccountCalendar calendar) + { + var outlookCalendarId = calendar.RemoteCalendarId; + try + { + Console.WriteLine($" 🔄 Full sync for calendar: {outlookCalendarId}"); + + // Get the database calendar + var dbCalendar = await _outlookChangeProcessor.GetCalendarByRemoteIdAsync($"{outlookCalendarId}"); + if (dbCalendar == null) + { + Console.WriteLine($" ❌ Database calendar not found: {outlookCalendarId}"); + return; + } + + // Step 1: Perform initial delta query to get all events and establish baseline + Console.WriteLine($" 📥 Fetching all events using delta endpoint..."); + + // Use the delta endpoint to get all events - this establishes the initial state + // IMPORTANT: We must include includeDeletedEvents=true even in the initial query + // to ensure the delta token supports deleted events in subsequent calls + var requestUrl = $"https://graph.microsoft.com/v1.0/me/calendars/{outlookCalendarId}/events/delta?includeDeletedEvents=true"; + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(requestUrl) + }; + requestInfo.Headers.Add("Accept", "application/json"); + + var deltaRequest = await _graphClient.RequestAdapter.SendAsync(requestInfo, Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); + + var allEvents = deltaRequest?.Value ?? new List(); + Console.WriteLine($" 📋 Processing {allEvents.Count} events from initial delta response..."); + + // Process all events from the initial response + foreach (var outlookEvent in allEvents) + { + await ProcessOutlookDeltaEventAsync(calendar.Id, outlookEvent, outlookCalendarId); + } + + // Step 2: Process pagination until we reach the deltaLink + var currentResponse = deltaRequest; + while (!string.IsNullOrEmpty(currentResponse?.OdataNextLink)) + { + Console.WriteLine($" 📄 Processing next page of events..."); + + // Get next page using the nextLink + var pageRequestInfo = new RequestInformation + { + HttpMethod = Method.GET, + URI = new Uri(currentResponse.OdataNextLink) + }; + pageRequestInfo.Headers.Add("Accept", "application/json"); + + currentResponse = await _graphClient.RequestAdapter.SendAsync(pageRequestInfo, Microsoft.Graph.Me.Calendars.Item.Events.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); + + var pageEvents = currentResponse?.Value ?? new List(); + Console.WriteLine($" 📋 Processing {pageEvents.Count} events from page..."); + + foreach (var outlookEvent in pageEvents) + { + await SynchronizeEventAsync(dbCalendar.Id, outlookEvent); + } + } + + // Step 3: Extract and save the delta token for future incremental syncs + var deltaToken = ExtractDeltaTokenFromInitResponse(currentResponse); + if (!string.IsNullOrEmpty(deltaToken)) + { + await _outlookChangeProcessor.UpdateCalendarSyncTokenAsync($"{outlookCalendarId}", deltaToken); + Console.WriteLine($" 🎯 Delta token saved for future incremental syncs"); + Console.WriteLine($" 📄 Token: {deltaToken.Substring(0, Math.Min(80, deltaToken.Length))}..."); + } + else + { + Console.WriteLine($" ⚠️ Warning: No delta token received - will retry on next sync"); + } + + Console.WriteLine($" ✅ Full synchronization completed for calendar: {outlookCalendarId}"); + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error during full sync for calendar {outlookCalendarId}: {ex.Message}"); + throw; + } + } + + public async Task DeltaSynchronizeCalendarAsync(AccountCalendar calendar) + { + var outlookCalendarId = calendar.RemoteCalendarId; + + try + { + Console.WriteLine($" 🔍 Starting delta sync for calendar: {outlookCalendarId}"); + + var dbCalendarId = $"{outlookCalendarId}"; + var deltaToken = await _outlookChangeProcessor.GetCalendarSyncTokenAsync(dbCalendarId); + + // Check if we have a valid delta token + if (string.IsNullOrEmpty(deltaToken)) + { + Console.WriteLine($" ❌ No delta token found. Please run Initialize Sync Tokens first (Option 18)."); + return false; + } + + Console.WriteLine($" ✅ Using stored delta token for incremental sync"); + + // Get the database calendar + var dbCalendar = await _outlookChangeProcessor.GetCalendarByRemoteIdAsync(dbCalendarId); + if (dbCalendar == null) + { + Console.WriteLine($" ❌ Calendar not found in database: {dbCalendarId}"); + return false; + } + + try + { + // Execute delta query using the stored delta URL + var eventsResponse = await ExecuteDeltaQueryAsync(deltaToken); + + if (eventsResponse?.Value != null && eventsResponse.Value.Count > 0) + { + Console.WriteLine($" 📥 Processing {eventsResponse.Value.Count} delta changes..."); + + // Process each changed event + foreach (var outlookEvent in eventsResponse.Value) + { + await ProcessOutlookDeltaEventAsync(dbCalendar.Id, outlookEvent, outlookCalendarId); + } + + // Process any additional pages + await ProcessDeltaEventsPaginationAsync(dbCalendar.Id, eventsResponse, outlookCalendarId); + } + else + { + Console.WriteLine($" 📭 No changes found for calendar"); + } + + // Extract and store the new delta token from @odata.deltaLink + var newDeltaToken = ExtractDeltaTokenFromResponse(eventsResponse); + if (!string.IsNullOrEmpty(newDeltaToken)) + { + await _outlookChangeProcessor.UpdateCalendarSyncTokenAsync(dbCalendarId, newDeltaToken); + Console.WriteLine($" 🔄 Updated delta token for future syncs"); + } + else + { + Console.WriteLine($" ⚠️ Warning: No new delta token received"); + } + + return true; + } + catch (Microsoft.Graph.Models.ODataErrors.ODataError odataError) when (odataError.ResponseStatusCode == 410) + { + // Delta token expired (HTTP 410 Gone) - recommend full sync + Console.WriteLine($" ⚠️ Delta token expired. Run Initialize Sync Tokens (Option 18) to reinitialize."); + return false; + } + } + catch (Exception ex) + { + Console.WriteLine($" ❌ Error during delta sync: {ex.Message}"); + return false; + } + } + + /// + /// Processes a single Outlook event change from delta synchronization + /// + /// The database calendar ID + /// The Outlook event + private async Task ProcessOutlookDeltaEventAsync(Guid calendarId, Microsoft.Graph.Models.Event outlookEvent, string? outlookCalendarId = null) + { + try + { + if (string.IsNullOrEmpty(outlookEvent.Id)) + { + return; + } + + // Check if this is a deleted event using various Microsoft Graph deletion indicators + bool isDeleted = false; + string deletionReason = ""; + + // Method 1: Check for @removed annotation in additional data (Microsoft Graph way of indicating deleted items) + if (outlookEvent.AdditionalData?.ContainsKey("@removed") == true) + { + isDeleted = true; + deletionReason = "Microsoft Graph @removed annotation"; + var removedInfo = outlookEvent.AdditionalData["@removed"]; + Console.WriteLine($"🗑️ Detected deleted event via @removed annotation: {outlookEvent.Id}"); + Console.WriteLine($" 📋 Removal info: {removedInfo}"); + } + // Method 2: Check for removal reason in additional data + else if (outlookEvent.AdditionalData?.ContainsKey("reason") == true) + { + var reason = outlookEvent.AdditionalData["reason"]?.ToString(); + if (reason == "deleted") + { + isDeleted = true; + deletionReason = "Microsoft Graph reason=deleted"; + Console.WriteLine($"🗑️ Detected deleted event via reason field: {outlookEvent.Id}"); + } + } + // Method 3: Check for @odata.context indicating a deleted item + else if (outlookEvent.AdditionalData?.ContainsKey("@odata.context") == true) + { + var context = outlookEvent.AdditionalData["@odata.context"]?.ToString(); + if (context?.Contains("$entity") == true || context?.Contains("deleted") == true) + { + isDeleted = true; + deletionReason = "Microsoft Graph @odata.context indicates deletion"; + Console.WriteLine($"🗑️ Detected deleted event via @odata.context: {outlookEvent.Id}"); + } + } + // Method 4: Check if the event is marked as cancelled + else if (outlookEvent.IsCancelled == true) + { + isDeleted = true; + deletionReason = "Event marked as cancelled"; + Console.WriteLine($"🗑️ Detected cancelled event: {outlookEvent.Subject ?? outlookEvent.Id}"); + } + // Method 5: Check if all important properties are null/empty (indicating a minimal deleted event response) + else if (string.IsNullOrEmpty(outlookEvent.Subject) && + outlookEvent.Start == null && + outlookEvent.End == null && + outlookEvent.Organizer == null && + outlookEvent.Body?.Content == null) + { + // This might be a deleted event with minimal data - but be cautious + Console.WriteLine($"🔍 Possible deleted event (minimal data): {outlookEvent.Id}"); + Console.WriteLine($" 📋 Event has only ID, no other properties - investigating..."); + + // Try to fetch the event directly to confirm if it's deleted + try + { + // Get the Outlook calendar ID if not provided + if (string.IsNullOrEmpty(outlookCalendarId)) + { + var allCalendars = await _outlookChangeProcessor.GetAllCalendarsAsync(); + var dbCalendar2 = allCalendars.FirstOrDefault(c => c.Id == calendarId); + outlookCalendarId = dbCalendar2?.RemoteCalendarId.Replace("", ""); + } + + if (!string.IsNullOrEmpty(outlookCalendarId)) + { + await _graphClient.Me.Calendars[outlookCalendarId].Events[outlookEvent.Id].GetAsync(); + Console.WriteLine($" ✅ Event exists, not deleted - will process normally"); + } + } + catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404) + { + // 404 confirms it's deleted + isDeleted = true; + deletionReason = "404 Not Found when fetching event details"; + Console.WriteLine($"🗑️ Confirmed deleted event (404 when fetching): {outlookEvent.Id}"); + } + catch (Exception) + { + // Other errors - treat as non-deleted but log + Console.WriteLine($" ⚠️ Could not verify deletion status, will process as normal event"); + } + } + + if (isDeleted) + { + // Handle deleted/canceled events + var eventId = $"{outlookEvent.Id}"; + await _outlookChangeProcessor.MarkEventAsDeletedAsync(eventId, $"{calendarId}"); + Console.WriteLine($"🗑️ Marked Outlook event as deleted: {outlookEvent.Subject ?? outlookEvent.Id}"); + Console.WriteLine($" 📋 Deletion reason: {deletionReason}"); + return; + } + + // For active events, fetch full event details from API to ensure we have all properties + try + { + // Get the Outlook calendar ID if not provided + if (string.IsNullOrEmpty(outlookCalendarId)) + { + var allCalendars = await _outlookChangeProcessor.GetAllCalendarsAsync(); + var dbCalendar = allCalendars.FirstOrDefault(c => c.Id == calendarId); + + if (dbCalendar == null) + { + Console.WriteLine($"❌ Database calendar not found for ID: {calendarId}"); + return; + } + + outlookCalendarId = dbCalendar.RemoteCalendarId.Replace("", ""); + } + + // Fetch the complete event with all properties + var fullEvent = await _graphClient.Me.Calendars[outlookCalendarId].Events[outlookEvent.Id].GetAsync(requestConfiguration => + { + requestConfiguration.QueryParameters.Select = new string[] { + "id", "subject", "start", "end", "location", "body", "attendees", + "organizer", "recurrence", "isAllDay", "isCancelled", + "createdDateTime", "lastModifiedDateTime" + }; + }); + + if (fullEvent != null) + { + // Process the full event data + await SynchronizeEventAsync(calendarId, fullEvent); + + var existingEvent = await _outlookChangeProcessor.GetEventByRemoteIdAsync($"{fullEvent.Id}"); + var action = existingEvent != null ? "Updated" : "Created"; + Console.WriteLine($"✅ {action} Outlook event: {fullEvent.Subject ?? "No Subject"} ({fullEvent.Id})"); + } + else + { + Console.WriteLine($"⚠️ Could not fetch full event details for {outlookEvent.Id}"); + // Fallback to processing the delta event as-is + await SynchronizeEventAsync(calendarId, outlookEvent); + var existingEvent = await _outlookChangeProcessor.GetEventByRemoteIdAsync($"{outlookEvent.Id}"); + var action = existingEvent != null ? "Updated" : "Created"; + Console.WriteLine($"✅ {action} Outlook event (partial): {outlookEvent.Subject ?? "No Subject"} ({outlookEvent.Id})"); + } + } + catch (Microsoft.Graph.Models.ODataErrors.ODataError odataError) when (odataError.ResponseStatusCode == 404) + { + // If we get a 404 when trying to fetch the event, it means it was deleted + Console.WriteLine($"🗑️ Event {outlookEvent.Id} was deleted (404 Not Found)"); + var eventId = $"{outlookEvent.Id}"; + await _outlookChangeProcessor.MarkEventAsDeletedAsync(eventId, $"{calendarId}"); + Console.WriteLine($"🗑️ Marked Outlook event as deleted: {outlookEvent.Subject ?? outlookEvent.Id}"); + } + catch (Exception fetchEx) + { + Console.WriteLine($"⚠️ Failed to fetch full event details for {outlookEvent.Id}: {fetchEx.Message}"); + // Fallback to processing the delta event as-is + await SynchronizeEventAsync(calendarId, outlookEvent); + var existingEvent = await _outlookChangeProcessor.GetEventByRemoteIdAsync($"{outlookEvent.Id}"); + var action = existingEvent != null ? "Updated" : "Created"; + Console.WriteLine($"✅ {action} Outlook event (fallback): {outlookEvent.Subject ?? "No Subject"} ({outlookEvent.Id})"); + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to process Outlook delta event {outlookEvent.Id}: {ex.Message}"); + } + } + + /// + /// Synchronizes a single Outlook event + /// + /// The database calendar ID + /// The Outlook event to synchronize + private async Task SynchronizeEventAsync(Guid calendarId, Microsoft.Graph.Models.Event outlookEvent) + { + try + { + if (string.IsNullOrEmpty(outlookEvent.Id)) + { + return; + } + + // Check if event already exists + var existingEvent = await _outlookChangeProcessor.GetEventByRemoteIdAsync($"{outlookEvent.Id}"); + + var eventData = new CalendarItem + { + CalendarId = calendarId, + RemoteEventId = outlookEvent.Id, + Title = outlookEvent.Subject ?? "No Subject", + Description = outlookEvent.Body?.Content ?? "", + Location = outlookEvent.Location?.DisplayName ?? "", + StartDateTime = ParseEventDateTime(outlookEvent.Start), + EndDateTime = ParseEventDateTime(outlookEvent.End), + IsAllDay = outlookEvent.IsAllDay ?? false, + OrganizerDisplayName = outlookEvent.Organizer?.EmailAddress?.Name, + OrganizerEmail = outlookEvent.Organizer?.EmailAddress?.Address, + RecurrenceRules = FormatRecurrence(outlookEvent.Recurrence), + Status = outlookEvent.IsCancelled == true ? "cancelled" : "confirmed", + IsDeleted = outlookEvent.IsCancelled == true, + LastModified = DateTime.UtcNow + }; + + // Automatically determine the calendar item type based on event properties + eventData.DetermineItemType(); + + if (existingEvent != null) + { + // Update existing event + eventData.Id = existingEvent.Id; + eventData.CreatedDate = existingEvent.CreatedDate; + await _outlookChangeProcessor.UpdateEventAsync(eventData); + } + else + { + // Create new event + eventData.Id = Guid.NewGuid(); + eventData.CreatedDate = DateTime.UtcNow; + await _outlookChangeProcessor.InsertEventAsync(eventData); + } + + // Synchronize attendees for this event + Console.WriteLine($"Synchronizing attendees for event: {outlookEvent.Subject}"); + await SynchronizeEventAttendeesAsync(eventData.Id, outlookEvent.Attendees); + } + catch (Exception ex) + { + Console.WriteLine($"Error synchronizing event {outlookEvent.Subject}: {ex.Message}"); + // Continue with other events + } + } + + /// + /// Formats Outlook recurrence information with enhanced BYDAY and BYMONTHDAY support + /// + /// Outlook recurrence pattern + /// Formatted recurrence string in RRULE format + private string FormatRecurrence(Microsoft.Graph.Models.PatternedRecurrence? recurrence) + { + if (recurrence?.Pattern == null) + { + return ""; + } + + var pattern = recurrence.Pattern; + var parts = new List(); + + // Basic frequency mapping + var freq = pattern.Type switch + { + Microsoft.Graph.Models.RecurrencePatternType.Daily => "DAILY", + Microsoft.Graph.Models.RecurrencePatternType.Weekly => "WEEKLY", + Microsoft.Graph.Models.RecurrencePatternType.AbsoluteMonthly => "MONTHLY", + Microsoft.Graph.Models.RecurrencePatternType.RelativeMonthly => "MONTHLY", + Microsoft.Graph.Models.RecurrencePatternType.AbsoluteYearly => "YEARLY", + Microsoft.Graph.Models.RecurrencePatternType.RelativeYearly => "YEARLY", + _ => "DAILY" + }; + parts.Add($"FREQ={freq}"); + + // Interval + if (pattern.Interval > 1) + { + parts.Add($"INTERVAL={pattern.Interval}"); + } + + // Handle BYDAY for weekly and monthly patterns + if (pattern.DaysOfWeek != null && pattern.DaysOfWeek.Any()) + { + var byDayValues = new List(); + + foreach (var dayOfWeekObj in pattern.DaysOfWeek) + { + // Convert DayOfWeekObject to string representation + string? dayCode = null; + try + { + // Use ToString() to get the day of week representation + var dayString = dayOfWeekObj?.ToString()?.ToLowerInvariant(); + dayCode = dayString switch + { + "sunday" => "SU", + "monday" => "MO", + "tuesday" => "TU", + "wednesday" => "WE", + "thursday" => "TH", + "friday" => "FR", + "saturday" => "SA", + _ => null + }; + } + catch + { + // If conversion fails, skip this day + continue; + } + + if (dayCode != null) + { + // For relative monthly patterns (e.g., first Monday, last Friday) + if (pattern.Type == Microsoft.Graph.Models.RecurrencePatternType.RelativeMonthly && pattern.Index != null) + { + var indexCode = pattern.Index switch + { + Microsoft.Graph.Models.WeekIndex.First => "1", + Microsoft.Graph.Models.WeekIndex.Second => "2", + Microsoft.Graph.Models.WeekIndex.Third => "3", + Microsoft.Graph.Models.WeekIndex.Fourth => "4", + Microsoft.Graph.Models.WeekIndex.Last => "-1", + _ => "" + }; + if (!string.IsNullOrEmpty(indexCode)) + { + byDayValues.Add($"{indexCode}{dayCode}"); + } + } + else + { + byDayValues.Add(dayCode); + } + } + } + + if (byDayValues.Any()) + { + parts.Add($"BYDAY={string.Join(",", byDayValues)}"); + } + } + + // Handle BYMONTHDAY for absolute monthly patterns + if (pattern.Type == Microsoft.Graph.Models.RecurrencePatternType.AbsoluteMonthly && pattern.DayOfMonth > 0) + { + parts.Add($"BYMONTHDAY={pattern.DayOfMonth}"); + } + + // Handle BYMONTH for yearly patterns + if ((pattern.Type == Microsoft.Graph.Models.RecurrencePatternType.AbsoluteYearly || + pattern.Type == Microsoft.Graph.Models.RecurrencePatternType.RelativeYearly) && + pattern.Month > 0) + { + parts.Add($"BYMONTH={pattern.Month}"); + } + + // Handle COUNT and UNTIL from recurrence range + if (recurrence.Range != null) + { + switch (recurrence.Range.Type) + { + case Microsoft.Graph.Models.RecurrenceRangeType.Numbered: + if (recurrence.Range.NumberOfOccurrences > 0) + { + parts.Add($"COUNT={recurrence.Range.NumberOfOccurrences}"); + } + break; + + case Microsoft.Graph.Models.RecurrenceRangeType.EndDate: + if (recurrence.Range.EndDate != null) + { + // Convert Microsoft.Kiota.Abstractions.Date to DateTime + try + { + var endDateString = recurrence.Range.EndDate.ToString(); + if (DateTime.TryParse(endDateString, out var endDate)) + { + // Convert to RRULE UNTIL format (YYYYMMDDTHHMMSSZ) + var utcEndDate = endDate.ToUniversalTime(); + parts.Add($"UNTIL={utcEndDate:yyyyMMddTHHmmss}Z"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not parse end date for recurrence: {ex.Message}"); + } + } + break; + } + } + + // Return empty string if no parts were added + if (parts.Count == 0) + { + return ""; + } + + // Join the parts and add the RRULE: prefix for compatibility with ExpandRecurringEvent + return $"RRULE:{string.Join(";", parts)}"; + } + + /// + /// Parses Outlook event date/time + /// + /// Outlook DateTimeTimeZone + /// Parsed DateTime + private DateTime ParseEventDateTime(Microsoft.Graph.Models.DateTimeTimeZone? dateTime) + { + if (dateTime?.DateTime == null) + { + return DateTime.UtcNow; + } + + if (DateTime.TryParse(dateTime.DateTime, out var parsed)) + { + // Convert to UTC if timezone info is available + if (!string.IsNullOrEmpty(dateTime.TimeZone) && dateTime.TimeZone != "UTC") + { + try + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(dateTime.TimeZone); + return TimeZoneInfo.ConvertTimeToUtc(parsed, timeZone); + } + catch + { + // If timezone conversion fails, assume the time is already in the correct zone + return parsed; + } + } + return parsed; + } + + return DateTime.UtcNow; + } + + /// + /// Synchronizes attendees for an event + /// + /// The database event ID + /// The Outlook attendees + private async Task SynchronizeEventAttendeesAsync(Guid eventId, IList? outlookAttendees) + { + try + { + // Clear existing attendees for this event + await _outlookChangeProcessor.DeleteCalendarEventAttendeesForEventAsync(eventId); + + if (outlookAttendees == null || !outlookAttendees.Any()) + { + Console.WriteLine($"No attendees found for event {eventId}"); + return; + } + + Console.WriteLine($"Synchronizing {outlookAttendees.Count} attendees for event {eventId}"); + var attendees = new List(); + + foreach (var outlookAttendee in outlookAttendees) + { + if (outlookAttendee.EmailAddress?.Address == null) + { + Console.WriteLine($"Skipping attendee with no email address"); + continue; + } + + var attendee = new CalendarEventAttendee + { + Id = Guid.NewGuid(), + EventId = eventId, + Email = outlookAttendee.EmailAddress.Address, + DisplayName = outlookAttendee.EmailAddress.Name, + ResponseStatus = OutlookIntegratorExtensions.ConvertOutlookResponseStatus(outlookAttendee.Status?.Response), + IsOptional = outlookAttendee.Type == Microsoft.Graph.Models.AttendeeType.Optional, + IsOrganizer = outlookAttendee.Status?.Response == Microsoft.Graph.Models.ResponseType.Organizer, + IsSelf = false, // Outlook doesn't provide this directly + Comment = "", // Outlook doesn't provide attendee comments + AdditionalGuests = 0, // Outlook doesn't provide this + CreatedDate = DateTime.UtcNow, + LastModified = DateTime.UtcNow + }; + + Console.WriteLine($"Adding attendee: {attendee.Email} ({attendee.ResponseStatus})"); + attendees.Add(attendee); + } + + // Add all attendees + foreach (var attendee in attendees) + { + await _outlookChangeProcessor.InsertCalendarEventAttendeeAsync(attendee); + } + + Console.WriteLine($"Successfully synchronized {attendees.Count} attendees"); + } + catch (Exception ex) + { + Console.WriteLine($"Error synchronizing attendees for event: {ex.Message}"); + } + } + private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default) { var calendars = await _graphClient.Me.Calendars.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 3f31175c..1d0c294a 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -1,16 +1,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; -using Ical.Net.CalendarComponents; -using Ical.Net.DataTypes; using SqlKata; -using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; -using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Messaging.Client.Calendar; @@ -58,27 +51,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService public async Task DeleteCalendarItemAsync(Guid calendarItemId) { - var calendarItem = await Connection.GetAsync(calendarItemId); - if (calendarItem == null) return; - - List eventsToRemove = new() { calendarItem }; - - // In case of parent event, delete all child events as well. - if (!string.IsNullOrEmpty(calendarItem.Recurrence)) - { - var recurringEvents = await Connection.Table().Where(a => a.RecurringCalendarItemId == calendarItemId).ToListAsync().ConfigureAwait(false); - - eventsToRemove.AddRange(recurringEvents); - } - - foreach (var @event in eventsToRemove) - { - await Connection.Table().DeleteAsync(x => x.Id == @event.Id).ConfigureAwait(false); - await Connection.Table().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false); - - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(@event)); - } } public async Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List attendees) @@ -98,83 +71,8 @@ public class CalendarService : BaseDatabaseService, ICalendarService public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel) { - // TODO: We might need to implement caching here. - // I don't know how much of the events we'll have in total, but this logic scans all events every time for given calendar. - - var accountEvents = await Connection.Table() - .Where(x => x.CalendarId == calendar.Id && !x.IsHidden).ToListAsync(); - - var result = new List(); - - foreach (var ev in accountEvents) - { - ev.AssignedCalendar = calendar; - - // Parse recurrence rules - var calendarEvent = new CalendarEvent - { - Start = new CalDateTime(ev.StartDate), - End = new CalDateTime(ev.EndDate), - }; - - if (string.IsNullOrEmpty(ev.Recurrence)) - { - // No recurrence, only check if we fall into the given period. - - if (ev.Period.OverlapsWith(dayRangeRenderModel.Period)) - { - result.Add(ev); - } - } - else - { - // This event has recurrences. - // Wino stores exceptional recurrent events as a separate calendar item, without the recurrence rule. - // Because each instance of recurrent event can have different attendees, properties etc. - // Even though the event is recurrent, each updated instance is a separate calendar item. - // Calculate the all recurrences, and remove the exceptional instances like hidden ones. - - var recurrenceLines = Regex.Split(ev.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator); - - foreach (var line in recurrenceLines) - { - calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line)); - } - - // Calculate occurrences in the range. - var occurrences = calendarEvent.GetOccurrences(dayRangeRenderModel.Period.Start, dayRangeRenderModel.Period.End); - - // Get all recurrent exceptional calendar events. - var exceptionalRecurrences = await Connection.Table() - .Where(a => a.RecurringCalendarItemId == ev.Id) - .ToListAsync() - .ConfigureAwait(false); - - foreach (var occurrence in occurrences) - { - var exactInstanceCheck = exceptionalRecurrences.FirstOrDefault(a => - a.Period.OverlapsWith(dayRangeRenderModel.Period)); - - if (exactInstanceCheck == null) - { - // There is no exception for the period. - // Change the instance StartDate and Duration. - - var recurrence = ev.CreateRecurrence(occurrence.Period.StartTime.Value, occurrence.Period.Duration.TotalSeconds); - - result.Add(recurrence); - } - else - { - // There is a single instance of this recurrent event. - // It will be added as single item if it's not hidden. - // We don't need to do anything here. - } - } - } - } - - return result; + // TODO + return new List(); } public Task GetAccountCalendarAsync(Guid accountCalendarId) @@ -221,7 +119,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService } public Task> GetAttendeesAsync(Guid calendarEventTrackingId) - => Connection.Table().Where(x => x.CalendarItemId == calendarEventTrackingId).ToListAsync(); + => Connection.Table().Where(x => x.EventId == calendarEventTrackingId).ToListAsync(); public async Task> ManageEventAttendeesAsync(Guid calendarItemId, List allAttendees) { @@ -230,7 +128,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService // Clear all attendees. var query = new Query() .From(nameof(CalendarEventAttendee)) - .Where(nameof(CalendarEventAttendee.CalendarItemId), calendarItemId) + .Where(nameof(CalendarEventAttendee.EventId), calendarItemId) .AsDelete(); connection.Execute(query.GetRawQuery()); @@ -239,67 +137,12 @@ public class CalendarService : BaseDatabaseService, ICalendarService connection.InsertAll(allAttendees); }); - return await Connection.Table().Where(a => a.CalendarItemId == calendarItemId).ToListAsync(); + return await Connection.Table().Where(a => a.EventId == calendarItemId).ToListAsync(); } public async Task GetCalendarItemTargetAsync(CalendarItemTarget targetDetails) { - var eventId = targetDetails.Item.Id; - - // Get the event by Id first. - 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 is an occurrence of a recurring event. - // They don't exist in db. - - 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; - } - } + // TODO + return null; } } diff --git a/Wino.Services/CalendarServiceEx.cs b/Wino.Services/CalendarServiceEx.cs new file mode 100644 index 00000000..3a502aa8 --- /dev/null +++ b/Wino.Services/CalendarServiceEx.cs @@ -0,0 +1,1105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; + +namespace Wino.Services; + +public class CalendarServiceEx : BaseDatabaseService, ICalendarServiceEx +{ + public CalendarServiceEx(IDatabaseService databaseService) : base(databaseService) { } + + public async Task> GetAllEventsAsync() + { + return await Connection.Table() + .Where(e => !e.IsDeleted) + .ToListAsync(); + } + + /// + /// Gets all events from the database including soft-deleted ones + /// + /// List of all events including deleted ones + public async Task> GetAllEventsIncludingDeletedAsync() + { + return await Connection.Table().ToListAsync(); + } + + public async Task GetEventByRemoteIdAsync(string remoteEventId) + { + return await Connection.Table() + .Where(e => e.RemoteEventId == remoteEventId && !e.IsDeleted) + .FirstOrDefaultAsync(); + } + + public async Task> GetEventsInDateRangeAsync(DateTime startDate, DateTime endDate) + { + return await Connection.Table() + .Where(e => e.StartDateTime >= startDate && e.StartDateTime <= endDate && !e.IsDeleted) + .ToListAsync(); + } + + public async Task> GetRecurringEventsAsync() + { + return await Connection.Table() + .Where(e => !string.IsNullOrEmpty(e.RecurrenceRules) && !e.IsDeleted) + .ToListAsync(); + } + + public async Task InsertEventAsync(CalendarItem calendarItem) + { + calendarItem.Id = Guid.NewGuid(); + calendarItem.CreatedDate = DateTime.UtcNow; + calendarItem.LastModified = DateTime.UtcNow; + return await Connection.InsertAsync(calendarItem); + } + + public async Task UpdateEventAsync(CalendarItem calendarItem) + { + calendarItem.LastModified = DateTime.UtcNow; + return await Connection.UpdateAsync(calendarItem); + } + + public async Task UpsertEventAsync(CalendarItem calendarItem) + { + var existingEvent = await GetEventByRemoteIdAsync(calendarItem.RemoteEventId); + if (existingEvent != null) + { + calendarItem.Id = existingEvent.Id; + calendarItem.CreatedDate = existingEvent.CreatedDate; + return await UpdateEventAsync(calendarItem); + } + else + { + return await InsertEventAsync(calendarItem); + } + } + + public async Task DeleteEventAsync(string remoteEventId) + { + var existingEvent = await GetEventByRemoteIdAsync(remoteEventId); + if (existingEvent != null) + { + existingEvent.IsDeleted = true; + existingEvent.LastModified = DateTime.UtcNow; + return await UpdateEventAsync(existingEvent); + } + return 0; + } + + public async Task HardDeleteEventAsync(string remoteEventId) + { + return await Connection.Table() + .DeleteAsync(e => e.RemoteEventId == remoteEventId); + } + + public async Task> GetEventsSinceLastSyncAsync(DateTime? lastSyncTime) + { + if (lastSyncTime == null) + { + return await GetAllEventsAsync(); + } + + return await Connection.Table() + .Where(e => e.LastModified > lastSyncTime && !e.IsDeleted) + .ToListAsync(); + } + + public async Task ClearAllEventsAsync() + { + return await Connection.DeleteAllAsync(); + } + + // Calendar management methods + public async Task> GetAllCalendarsAsync() + { + return await Connection.Table() + .Where(c => !c.IsDeleted) + .ToListAsync(); + } + + public async Task GetCalendarByRemoteIdAsync(string remoteCalendarId) + { + return await Connection.Table() + .Where(c => c.RemoteCalendarId == remoteCalendarId && !c.IsDeleted) + .FirstOrDefaultAsync(); + } + + public async Task InsertCalendarAsync(AccountCalendar calendar) + { + calendar.Id = Guid.NewGuid(); + calendar.CreatedDate = DateTime.UtcNow; + calendar.LastModified = DateTime.UtcNow; + return await Connection.InsertAsync(calendar); + } + + public async Task UpdateCalendarAsync(AccountCalendar calendar) + { + calendar.LastModified = DateTime.UtcNow; + return await Connection.UpdateAsync(calendar); + } + + public async Task UpsertCalendarAsync(AccountCalendar calendar) + { + var existingCalendar = await GetCalendarByRemoteIdAsync(calendar.RemoteCalendarId); + if (existingCalendar != null) + { + calendar.Id = existingCalendar.Id; + calendar.CreatedDate = existingCalendar.CreatedDate; + return await UpdateCalendarAsync(calendar); + } + else + { + return await InsertCalendarAsync(calendar); + } + } + + public async Task DeleteCalendarAsync(string remoteCalendarId) + { + var existingCalendar = await GetCalendarByRemoteIdAsync(remoteCalendarId); + if (existingCalendar != null) + { + existingCalendar.IsDeleted = true; + existingCalendar.LastModified = DateTime.UtcNow; + return await UpdateCalendarAsync(existingCalendar); + } + return 0; + } + + /// + /// Gets events for a specific calendar by internal Guid + /// + /// The internal Guid of the calendar + /// List of events for the specified calendar + public async Task> GetEventsForCalendarAsync(Guid calendarId) + { + return await Connection.Table() + .Where(e => e.CalendarId == calendarId && !e.IsDeleted) + .ToListAsync(); + } + + /// + /// Gets events for a specific calendar by Remote Calendar ID + /// + /// The Remote Calendar ID + /// List of events for the specified calendar + public async Task> GetEventsByremoteCalendarIdAsync(string remoteCalendarId) + { + // First get the calendar to find its internal Guid + var calendar = await GetCalendarByRemoteIdAsync(remoteCalendarId); + if (calendar == null) + { + return new List(); + } + + return await GetEventsForCalendarAsync(calendar.Id); + } + + public async Task ClearAllCalendarsAsync() + { + return await Connection.DeleteAllAsync(); + } + + public async Task ClearAllDataAsync() + { + var calendarCount = await Connection.DeleteAllAsync(); + var eventCount = await Connection.DeleteAllAsync(); + var calendareventattendeeCount = await Connection.DeleteAllAsync(); + return calendarCount + eventCount + calendareventattendeeCount; + } + + /// + /// Gets all events (including expanded recurring event instances) within a date range + /// + /// Start date of the range + /// End date of the range + /// List of events including expanded recurring instances + public async Task> GetExpandedEventsInDateRangeAsync(DateTime startDate, DateTime endDate) + { + var allEvents = new List(); + + // Get all non-recurring events in the date range + var oneTimeEvents = await Connection.Table() + .Where(e => !e.IsDeleted && + (string.IsNullOrEmpty(e.RecurrenceRules) || e.RecurrenceRules == "") && + e.StartDateTime >= startDate && e.StartDateTime <= endDate) + .ToListAsync(); + + allEvents.AddRange(oneTimeEvents); + + // Get all recurring events + var recurringEvents = await Connection.Table() + .Where(e => !e.IsDeleted && + !string.IsNullOrEmpty(e.RecurrenceRules) && + e.RecurrenceRules != "") + .ToListAsync(); + + // Expand recurring events + foreach (var recurringEvent in recurringEvents) + { + var expandedInstances = ExpandRecurringEvent(recurringEvent, startDate, endDate); + allEvents.AddRange(expandedInstances); + } + + // Sort by start date and return + return allEvents.OrderBy(e => e.StartDateTime).ToList(); + } + + /// + /// Expands a recurring event into individual instances within the specified date range + /// + /// The recurring event to expand + /// Start of the date range + /// End of the date range + /// List of event instances + private List ExpandRecurringEvent(CalendarItem recurringEvent, DateTime rangeStart, DateTime rangeEnd) + { + var instances = new List(); + + if (string.IsNullOrEmpty(recurringEvent.RecurrenceRules)) + return instances; + + try + { + var recurrenceRules = recurringEvent.RecurrenceRules.Split(';'); + var rrule = recurrenceRules.FirstOrDefault(r => r.StartsWith("RRULE:")); + + if (string.IsNullOrEmpty(rrule)) + return instances; + + // Parse RRULE + var ruleData = ParseRRule(rrule.Substring(6)); // Remove "RRULE:" prefix + + if (ruleData == null || !ruleData.ContainsKey("FREQ")) + return instances; + + var frequency = ruleData["FREQ"]; + var interval = ruleData.ContainsKey("INTERVAL") ? int.Parse(ruleData["INTERVAL"]) : 1; + var count = ruleData.ContainsKey("COUNT") ? int.Parse(ruleData["COUNT"]) : (int?)null; + var until = ruleData.ContainsKey("UNTIL") ? ParseUntilDate(ruleData["UNTIL"]) : (DateTime?)null; + + // Calculate event duration + var duration = recurringEvent.EndDateTime - recurringEvent.StartDateTime; + + // Start from the original event date + var currentDate = recurringEvent.StartDateTime; + var instanceCount = 0; + var maxInstances = count ?? 1000; // Limit to prevent infinite loops + + // Generate instances + while (instanceCount < maxInstances && + currentDate <= rangeEnd && + (until == null || currentDate <= until)) + { + // Check if this instance falls within our range + if (currentDate >= rangeStart && currentDate <= rangeEnd) + { + var instance = CreateEventInstance(recurringEvent, currentDate, duration, instanceCount); + instances.Add(instance); + } + + // Move to next occurrence based on frequency + currentDate = GetNextOccurrence(currentDate, frequency, interval, ruleData); + instanceCount++; + + // Safety check to prevent infinite loops + if (instanceCount > 10000) + break; + } + } + catch (Exception ex) + { + // Log error but don't crash - return empty list + Console.WriteLine($"Error expanding recurring event {recurringEvent.RemoteEventId}: {ex.Message}"); + } + + return instances; + } + + /// + /// Parses an RRULE string into a dictionary of key-value pairs + /// + /// The RRULE string (without RRULE: prefix) + /// Dictionary of rule parameters + private Dictionary? ParseRRule(string rrule) + { + try + { + var ruleData = new Dictionary(); + var parts = rrule.Split(';'); + + foreach (var part in parts) + { + var keyValue = part.Split('='); + if (keyValue.Length == 2) + { + ruleData[keyValue[0].Trim()] = keyValue[1].Trim(); + } + } + + return ruleData; + } + catch + { + return null; + } + } + + /// + /// Parses UNTIL date from RRULE + /// + /// UNTIL date string + /// Parsed DateTime or null + private DateTime? ParseUntilDate(string untilString) + { + try + { + // Handle different UNTIL formats + if (untilString.Length == 8) // YYYYMMDD + { + return DateTime.ParseExact(untilString, "yyyyMMdd", null); + } + else if (untilString.Length == 15 && untilString.EndsWith("Z")) // YYYYMMDDTHHMMSSZ + { + return DateTime.ParseExact(untilString, "yyyyMMddTHHmmssZ", null); + } + else if (untilString.Length == 16) // YYYYMMDDTHHMMSSZ without Z + { + return DateTime.ParseExact(untilString.Substring(0, 15), "yyyyMMddTHHmmss", null); + } + } + catch + { + // Return null if parsing fails + } + return null; + } + + /// + /// Creates an instance of a recurring event + /// + /// The original recurring event + /// Start time for this instance + /// Duration of the event + /// Instance number + /// Event instance + private CalendarItem CreateEventInstance(CalendarItem originalEvent, DateTime instanceStart, TimeSpan duration, int instanceNumber) + { + return new CalendarItem + { + Id = Guid.NewGuid(), + RemoteEventId = $"{originalEvent.RemoteEventId}_instance_{instanceNumber}", + CalendarId = originalEvent.CalendarId, + Title = originalEvent.Title, + Description = originalEvent.Description, + Location = originalEvent.Location, + StartDateTime = instanceStart, + EndDateTime = instanceStart + duration, + IsAllDay = originalEvent.IsAllDay, + TimeZone = originalEvent.TimeZone, + RecurrenceRules = "", // Instances don't have recurrence rules + Status = originalEvent.Status, + OrganizerDisplayName = originalEvent.OrganizerDisplayName, + OrganizerEmail = originalEvent.OrganizerEmail, + CreatedDate = originalEvent.CreatedDate, + LastModified = originalEvent.LastModified, + IsDeleted = false, + RecurringEventId = originalEvent.RemoteEventId, + OriginalStartTime = instanceStart.ToString("O") + }; + } + + /// + /// Calculates the next occurrence based on frequency and interval + /// + /// Current occurrence date + /// Frequency (DAILY, WEEKLY, MONTHLY, YEARLY) + /// Interval between occurrences + /// Additional rule data + /// Next occurrence date + private DateTime GetNextOccurrence(DateTime currentDate, string frequency, int interval, Dictionary ruleData) + { + switch (frequency.ToUpperInvariant()) + { + case "DAILY": + return currentDate.AddDays(interval); + + case "WEEKLY": + // Handle BYDAY for weekly recurrence + if (ruleData.ContainsKey("BYDAY")) + { + var byDays = ruleData["BYDAY"].Split(','); + return GetNextWeeklyOccurrence(currentDate, interval, byDays); + } + return currentDate.AddDays(7 * interval); + + case "MONTHLY": + // Handle BYMONTHDAY and BYDAY for monthly recurrence + if (ruleData.ContainsKey("BYMONTHDAY")) + { + var monthDay = int.Parse(ruleData["BYMONTHDAY"]); + return GetNextMonthlyByMonthDay(currentDate, interval, monthDay); + } + else if (ruleData.ContainsKey("BYDAY")) + { + return GetNextMonthlyByDay(currentDate, interval, ruleData["BYDAY"]); + } + return currentDate.AddMonths(interval); + + case "YEARLY": + return currentDate.AddYears(interval); + + default: + return currentDate.AddDays(interval); // Default to daily + } + } + + /// + /// Gets next weekly occurrence considering BYDAY rule + /// + private DateTime GetNextWeeklyOccurrence(DateTime currentDate, int interval, string[] byDays) + { + var dayMap = new Dictionary + { + {"SU", DayOfWeek.Sunday}, {"MO", DayOfWeek.Monday}, {"TU", DayOfWeek.Tuesday}, + {"WE", DayOfWeek.Wednesday}, {"TH", DayOfWeek.Thursday}, {"FR", DayOfWeek.Friday}, {"SA", DayOfWeek.Saturday} + }; + + var targetDays = byDays.Where(d => dayMap.ContainsKey(d)).Select(d => dayMap[d]).OrderBy(d => d).ToList(); + + if (!targetDays.Any()) + return currentDate.AddDays(7 * interval); + + var currentDayOfWeek = currentDate.DayOfWeek; + var nextDay = targetDays.FirstOrDefault(d => d > currentDayOfWeek); + + if (nextDay != default(DayOfWeek)) + { + // Next occurrence is later this week + var daysToAdd = (int)nextDay - (int)currentDayOfWeek; + return currentDate.AddDays(daysToAdd); + } + else + { + // Next occurrence is next week + var daysToAdd = (7 * interval) - (int)currentDayOfWeek + (int)targetDays.First(); + return currentDate.AddDays(daysToAdd); + } + } + + /// + /// Gets next monthly occurrence by month day + /// + private DateTime GetNextMonthlyByMonthDay(DateTime currentDate, int interval, int monthDay) + { + var nextMonth = currentDate.AddMonths(interval); + var daysInMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month); + var targetDay = Math.Min(monthDay, daysInMonth); + + return new DateTime(nextMonth.Year, nextMonth.Month, targetDay, + currentDate.Hour, currentDate.Minute, currentDate.Second); + } + + /// + /// Gets next monthly occurrence by day (e.g., first Monday, last Friday) + /// + private DateTime GetNextMonthlyByDay(DateTime currentDate, int interval, string byDay) + { + // This is a simplified implementation + // Full implementation would handle patterns like "1MO" (first Monday), "-1FR" (last Friday) + return currentDate.AddMonths(interval); + } + + /// + /// Gets all events (including expanded recurring event instances) within a date range with proper exception handling + /// + /// Start date of the range + /// End date of the range + /// List of events including expanded recurring instances, excluding canceled exceptions + public async Task> GetExpandedEventsInDateRangeWithExceptionsAsync(DateTime startDate, DateTime endDate, AccountCalendar calendar) + { + var allEvents = new List(); + + // Get all non-recurring events in the date range + var oneTimeEvents = await Connection.Table() + .Where(e => !e.IsDeleted && + (string.IsNullOrEmpty(e.RecurrenceRules) || e.RecurrenceRules == "") && + string.IsNullOrEmpty(e.RecurringEventId) && // Ensure it's not a modified instance + e.StartDateTime >= startDate && e.StartDateTime <= endDate + && e.CalendarId == calendar.Id) + .ToListAsync(); + + allEvents.AddRange(oneTimeEvents); + + // Get all recurring events (master events only) + var recurringEvents = await Connection.Table() + .Where(e => !e.IsDeleted && + !string.IsNullOrEmpty(e.RecurrenceRules) && + e.RecurrenceRules != "" && + string.IsNullOrEmpty(e.RecurringEventId) && + e.CalendarId == calendar.Id) // Master events, not instances + .ToListAsync(); + + // Get all exception instances (modified or moved instances) + var exceptionInstances = await Connection.Table() + .Where(e => !e.IsDeleted && + e.CalendarId == calendar.Id && + !string.IsNullOrEmpty(e.RecurringEventId) && + e.StartDateTime >= startDate && e.StartDateTime <= endDate) + .ToListAsync(); + + // Get all canceled instances (marked as deleted but with RecurringEventId) + var canceledInstances = await Connection.Table() + .Where(e => e.IsDeleted && + e.CalendarId == calendar.Id && + !string.IsNullOrEmpty(e.RecurringEventId) && + !string.IsNullOrEmpty(e.OriginalStartTime)) + .ToListAsync(); + + // Group exceptions and cancellations by their parent recurring event + var exceptionsByParent = exceptionInstances + .Where(e => !string.IsNullOrEmpty(e.RecurringEventId)) + .GroupBy(e => e.RecurringEventId!) + .ToDictionary(g => g.Key, g => g.ToList()); + + var canceledInstancesByParent = canceledInstances + .Where(e => !string.IsNullOrEmpty(e.RecurringEventId)) + .GroupBy(e => e.RecurringEventId!) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Expand recurring events with exception handling + foreach (var recurringEvent in recurringEvents) + { + var exceptions = exceptionsByParent.GetValueOrDefault(recurringEvent.RemoteEventId, new List()); + var canceled = canceledInstancesByParent.GetValueOrDefault(recurringEvent.RemoteEventId, new List()); + + var expandedInstances = ExpandRecurringEventWithExceptions(recurringEvent, startDate, endDate, exceptions, canceled); + allEvents.AddRange(expandedInstances); + } + + // Add the exception instances (modified/moved instances) to the final list + allEvents.AddRange(exceptionInstances); + + // Sort by start date and return + return allEvents.OrderBy(e => e.StartDateTime).ToList(); + } + + /// + /// Expands a recurring event into individual instances within the specified date range, with handling for exceptions and cancellations + /// + /// The recurring event to expand + /// Start of the date range + /// End of the date range + /// List of modified instances for this recurring event + /// List of canceled instances for this recurring event + /// List of event instances excluding canceled ones + private List ExpandRecurringEventWithExceptions( + CalendarItem recurringEvent, + DateTime rangeStart, + DateTime rangeEnd, + List exceptions, + List canceled) + { + var instances = new List(); + + if (string.IsNullOrEmpty(recurringEvent.RecurrenceRules)) + return instances; + + try + { + // Parse EXDATE (exception dates) from recurrence rules + var exceptionDates = ParseExceptionDates(recurringEvent.RecurrenceRules); + + // Create sets of canceled and modified dates for quick lookup + var canceledDates = new HashSet(); + var modifiedDates = new HashSet(); + + // Add canceled instances to the set + foreach (var canceledInstance in canceled) + { + if (!string.IsNullOrEmpty(canceledInstance.OriginalStartTime)) + { + if (DateTime.TryParse(canceledInstance.OriginalStartTime, out var originalDate)) + { + canceledDates.Add(originalDate.Date); + } + } + } + + // Add modified instances to the set + foreach (var exception in exceptions) + { + if (!string.IsNullOrEmpty(exception.OriginalStartTime)) + { + if (DateTime.TryParse(exception.OriginalStartTime, out var originalDate)) + { + modifiedDates.Add(originalDate.Date); + } + } + } + + // Generate base instances using existing logic + var baseInstances = ExpandRecurringEvent(recurringEvent, rangeStart, rangeEnd); + + // Filter out canceled, modified, and EXDATE instances + foreach (var instance in baseInstances) + { + var instanceDate = instance.StartDateTime.Date; + + // Skip if this instance is canceled + if (canceledDates.Contains(instanceDate)) + { + Console.WriteLine($"Skipping canceled instance: {instance.Title} on {instanceDate:MM/dd/yyyy}"); + continue; + } + + // Skip if this instance has been modified (the modified version will be added separately) + if (modifiedDates.Contains(instanceDate)) + { + Console.WriteLine($"Skipping modified instance: {instance.Title} on {instanceDate:MM/dd/yyyy} (modified version exists)"); + continue; + } + + // Skip if this date is in EXDATE list + if (exceptionDates.Contains(instanceDate)) + { + Console.WriteLine($"Skipping EXDATE instance: {instance.Title} on {instanceDate:MM/dd/yyyy}"); + continue; + } + + instances.Add(instance); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error expanding recurring event with exceptions {recurringEvent.RemoteEventId}: {ex.Message}"); + // Fall back to basic expansion without exception handling + return ExpandRecurringEvent(recurringEvent, rangeStart, rangeEnd); + } + + return instances; + } + + /// + /// Parses EXDATE (exception dates) from recurrence rules + /// + /// The full recurrence rules string + /// Set of exception dates + private HashSet ParseExceptionDates(string recurrenceRules) + { + var exceptionDates = new HashSet(); + + if (string.IsNullOrEmpty(recurrenceRules)) + return exceptionDates; + + try + { + var rules = recurrenceRules.Split(';'); + + foreach (var rule in rules) + { + if (rule.StartsWith("EXDATE")) + { + // Handle different EXDATE formats + // EXDATE:20250711T100000Z + // EXDATE;TZID=America/New_York:20250711T100000 + + var exdateValue = rule.Contains(':') ? rule.Split(':')[1] : rule; + var dates = exdateValue.Split(','); + + foreach (var dateStr in dates) + { + var cleanDateStr = dateStr.Trim(); + DateTime exceptionDate; + + // Try different date formats + if (cleanDateStr.Length == 8) // YYYYMMDD + { + if (DateTime.TryParseExact(cleanDateStr, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out exceptionDate)) + { + exceptionDates.Add(exceptionDate.Date); + } + } + else if (cleanDateStr.Length >= 15) // YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ + { + var dateOnly = cleanDateStr.Substring(0, 8); + if (DateTime.TryParseExact(dateOnly, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out exceptionDate)) + { + exceptionDates.Add(exceptionDate.Date); + } + } + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error parsing EXDATE: {ex.Message}"); + } + + return exceptionDates; + } + + /// + /// Stores sync token for a calendar to enable delta synchronization + /// + /// The calendar ID + /// The sync token from Remote Calendar API + public async Task UpdateCalendarSyncTokenAsync(string calendarId, string syncToken) + { + var calendar = await GetCalendarByRemoteIdAsync(calendarId); + if (calendar != null) + { + calendar.SynchronizationDeltaToken = syncToken; + calendar.LastSyncTime = DateTime.UtcNow; + return await UpdateCalendarAsync(calendar); + } + return 0; + } + + /// + /// Gets the sync token for a calendar + /// + /// The calendar ID + /// The sync token or null if not found + public async Task GetCalendarSyncTokenAsync(string calendarId) + { + var calendar = await GetCalendarByRemoteIdAsync(calendarId); + return calendar?.SynchronizationDeltaToken; + } + + /// + /// Marks an event as deleted (soft delete) for delta sync + /// + /// The Remote event ID + /// The Remote calendar ID + public async Task MarkEventAsDeletedAsync(string remoteEventId, string remoteCalendarId) + { + var existingEvent = await GetEventByRemoteIdAsync(remoteEventId); + if (existingEvent != null) + { + existingEvent.IsDeleted = true; + existingEvent.Status = "cancelled"; + existingEvent.LastModified = DateTime.UtcNow; + return await UpdateEventAsync(existingEvent); + } + + // If event doesn't exist locally, create a placeholder for the cancellation + // First get the calendar to find its internal Guid + var calendar = await GetCalendarByRemoteIdAsync(remoteCalendarId); + if (calendar == null) + { + throw new InvalidOperationException($"Calendar not found for Remote Calendar ID: {remoteCalendarId}"); + } + + var canceledEvent = new CalendarItem + { + Id = Guid.NewGuid(), + RemoteEventId = remoteEventId, + CalendarId = calendar.Id, + Title = "[Canceled Event]", + Status = "cancelled", + IsDeleted = true, + CreatedDate = DateTime.UtcNow, + LastModified = DateTime.UtcNow, + StartDateTime = DateTime.MinValue, + EndDateTime = DateTime.MinValue + }; + + return await Connection.InsertAsync(canceledEvent); + } + + /// + /// Gets the last synchronization time for a calendar + /// + /// The calendar ID + /// Last sync time or null if never synced + public async Task GetLastSyncTimeAsync(string calendarId) + { + var calendar = await GetCalendarByRemoteIdAsync(calendarId); + return calendar?.LastSyncTime; + } + + // CalendarEventAttendee management methods + + /// + /// Gets all calendareventattendees for a specific event + /// + /// The internal event Guid + /// List of calendareventattendees for the event + public async Task> GetCalendarEventAttendeesForEventAsync(Guid eventId) + { + return await Connection.Table() + .Where(a => a.EventId == eventId) + .OrderBy(a => a.DisplayName ?? a.Email) + .ToListAsync(); + } + + /// + /// Gets all calendareventattendees for a specific event by Remote Event ID + /// + /// The Remote Event ID + /// List of calendareventattendees for the event + public async Task> GetCalendarEventAttendeesForEventByRemoteIdAsync(string remoteEventId) + { + var calendarItem = await GetEventByRemoteIdAsync(remoteEventId); + if (calendarItem == null) + { + return new List(); + } + + return await GetCalendarEventAttendeesForEventAsync(calendarItem.Id); + } + + /// + /// Inserts a new calendareventattendee + /// + /// The calendareventattendee to insert + /// Number of rows affected + public async Task InsertCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee) + { + calendareventattendee.Id = Guid.NewGuid(); + calendareventattendee.CreatedDate = DateTime.UtcNow; + calendareventattendee.LastModified = DateTime.UtcNow; + return await Connection.InsertAsync(calendareventattendee); + } + + /// + /// Updates an existing calendareventattendee + /// + /// The calendareventattendee to update + /// Number of rows affected + public async Task UpdateCalendarEventAttendeeAsync(CalendarEventAttendee calendareventattendee) + { + calendareventattendee.LastModified = DateTime.UtcNow; + return await Connection.UpdateAsync(calendareventattendee); + } + + /// + /// Syncs calendareventattendees for an event (replaces all existing calendareventattendees) + /// + /// The internal event Guid + /// List of calendareventattendees to sync + /// Number of calendareventattendees synced + public async Task SyncCalendarEventAttendeesForEventAsync(Guid eventId, List calendareventattendees) + { + // Delete existing calendareventattendees for this event + await Connection.Table() + .Where(a => a.EventId == eventId) + .DeleteAsync(); + + // Insert new calendareventattendees + int syncedCount = 0; + foreach (var calendareventattendee in calendareventattendees) + { + calendareventattendee.EventId = eventId; + await InsertCalendarEventAttendeeAsync(calendareventattendee); + syncedCount++; + } + + return syncedCount; + } + + /// + /// Deletes all calendareventattendees for a specific event + /// + /// The internal event Guid + /// Number of calendareventattendees deleted + public async Task DeleteCalendarEventAttendeesForEventAsync(Guid eventId) + { + return await Connection.Table() + .Where(a => a.EventId == eventId) + .DeleteAsync(); + } + + /// + /// Gets calendareventattendee count by response status + /// + /// The internal event Guid + /// Dictionary with response status counts + public async Task> GetCalendarEventAttendeeResponseCountsAsync(Guid eventId) + { + var calendareventattendees = await GetCalendarEventAttendeesForEventAsync(eventId); + return calendareventattendees + .GroupBy(a => a.ResponseStatus) + .ToDictionary(g => g.Key, g => g.Count()); + } + + /// + /// Clears all calendareventattendees from the database + /// + /// Number of calendareventattendees deleted + public async Task ClearAllCalendarEventAttendeesAsync() + { + return await Connection.DeleteAllAsync(); + } + + /// + /// Gets all calendareventattendees from the database + /// + /// List of all calendareventattendees + public async Task> GetAllCalendarEventAttendeesAsync() + { + return await Connection.Table().ToListAsync(); + } + + /// + /// Gets events by calendar item type + /// + /// The calendar item type to filter by + /// List of events matching the item type + public async Task> GetEventsByItemTypeAsync(CalendarItemType itemType) + { + return await Connection.Table() + .Where(e => !e.IsDeleted && e.ItemType == itemType) + .OrderBy(e => e.StartDateTime) + .ToListAsync(); + } + + /// + /// Gets events by multiple calendar item types + /// + /// The calendar item types to filter by + /// List of events matching any of the item types + public async Task> GetEventsByItemTypesAsync(params CalendarItemType[] itemTypes) + { + var events = await Connection.Table() + .Where(e => !e.IsDeleted) + .ToListAsync(); + + return events + .Where(e => itemTypes.Contains(e.ItemType)) + .OrderBy(e => e.StartDateTime) + .ToList(); + } + + /// + /// Gets all-day events (all types of all-day events) + /// + /// List of all-day events + public async Task> GetAllDayEventsAsync() + { + return await GetEventsByItemTypesAsync( + CalendarItemType.AllDay, + CalendarItemType.MultiDayAllDay, + CalendarItemType.RecurringAllDay); + } + + /// + /// Gets all recurring events by item type (all types of recurring events) + /// + /// List of recurring events + public async Task> GetAllRecurringEventsByTypeAsync() + { + return await GetEventsByItemTypesAsync( + CalendarItemType.Recurring, + CalendarItemType.RecurringAllDay, + CalendarItemType.RecurringException); + } + + /// + /// Gets multi-day events (all types of multi-day events) + /// + /// List of multi-day events + public async Task> GetMultiDayEventsAsync() + { + return await GetEventsByItemTypesAsync( + CalendarItemType.MultiDay, + CalendarItemType.MultiDayAllDay); + } + + /// + /// Gets event statistics grouped by item type + /// + /// Dictionary with item type counts + public async Task> GetEventStatsByItemTypeAsync() + { + var allEvents = await Connection.Table() + .Where(e => !e.IsDeleted) + .ToListAsync(); + + return allEvents + .GroupBy(e => e.ItemType) + .ToDictionary(g => g.Key, g => g.Count()); + } + + /// + /// Updates all existing events to determine their item types + /// This is useful for migrating existing data to use the new ItemType property + /// + /// Number of events updated + public async Task UpdateAllEventItemTypesAsync() + { + var allEvents = await Connection.Table() + .ToListAsync(); + + int updatedCount = 0; + foreach (var calendarItem in allEvents) + { + var oldItemType = calendarItem.ItemType; + calendarItem.DetermineItemType(); + + if (oldItemType != calendarItem.ItemType) + { + await Connection.UpdateAsync(calendarItem); + updatedCount++; + } + } + + return updatedCount; + } + + + /// + /// Inserts a new attendee + /// + /// The attendee to insert + /// Number of rows affected + public async Task InsertAttendeeAsync(CalendarEventAttendee attendee) + { + attendee.Id = Guid.NewGuid(); + attendee.CreatedDate = DateTime.UtcNow; + attendee.LastModified = DateTime.UtcNow; + return await Connection.InsertAsync(attendee); + } + + /// + /// Updates an existing attendee + /// + /// The attendee to update + /// Number of rows affected + public async Task UpdateAttendeeAsync(CalendarEventAttendee attendee) + { + attendee.LastModified = DateTime.UtcNow; + return await Connection.UpdateAsync(attendee); + } + + /// + /// Syncs attendees for an event (replaces all existing attendees) + /// + /// The internal event Guid + /// List of attendees to sync + /// Number of attendees synced + public async Task SyncAttendeesForEventAsync(Guid eventId, List attendees) + { + // Delete existing attendees for this event + await Connection.Table() + .Where(a => a.EventId == eventId) + .DeleteAsync(); + + // Insert new attendees + int syncedCount = 0; + foreach (var attendee in attendees) + { + attendee.EventId = eventId; + await InsertAttendeeAsync(attendee); + syncedCount++; + } + + return syncedCount; + } + +} diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 894edc60..0098d8ae 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -17,6 +17,7 @@ public static class ServicesContainerSetup services.AddSingleton(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -29,7 +30,5 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); services.AddTransient(); - - } }