From f8333aab10f8bff152562c573b6f31bc0de66df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 6 Jan 2026 11:11:37 +0100 Subject: [PATCH] Single isntances and some updates shit. --- .../CalendarPageViewModel.cs | 100 ++--- .../Data/CalendarItemViewModel.cs | 108 +++++ .../Collections/CalendarEventCollection.cs | 54 ++- .../Entities/Calendar/CalendarItem.cs | 69 +--- Wino.Core.Domain/Interfaces/ICalendarItem.cs | 10 + .../Interfaces/ICalendarItemViewModel.cs | 23 +- .../Interfaces/ICalendarService.cs | 10 +- .../Services/CalendarServiceTests.cs | 379 ++++-------------- .../Processors/GmailChangeProcessor.cs | 3 + .../Processors/OutlookChangeProcessor.cs | 24 +- Wino.Core/Synchronizers/GmailSynchronizer.cs | 30 +- .../Synchronizers/OutlookSynchronizer.cs | 36 +- Wino.Core/Synchronizers/WinoSynchronizer.cs | 3 +- .../Calendar/CalendarItemControl.xaml | 4 +- .../Calendar/CalendarItemControl.xaml.cs | 87 +--- .../MailAuthenticatorConfiguration.cs | 12 +- .../Styles/WinoCalendarResources.xaml | 29 +- Wino.Services/CalendarService.cs | 233 +++-------- 18 files changed, 482 insertions(+), 732 deletions(-) diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 37a17e23..eafc36c9 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -906,14 +906,39 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, base.OnCalendarItemUpdated(calendarItem); Debug.WriteLine($"Calendar item updated: {calendarItem.Id}"); - // Updated item might've been from specific time to all-day event, or vice versa. - // Remove the event and its occurrences from all visible date ranges first. + // Series master events should not be visible on the UI. + if (calendarItem.IsRecurringParent) + { + Debug.WriteLine($"Skipping series master event update: {calendarItem.Title}"); + return; + } + + if (DayRanges.DisplayRange == null) return; + + // Find all days that currently have this item and days that should have it after update + var currentDaysWithItem = DayRanges + .SelectMany(a => a.CalendarDays) + .Where(day => day.EventsCollection.GetCalendarItem(calendarItem.Id) != null) + .ToList(); + + var targetDaysForItem = DayRanges + .SelectMany(a => a.CalendarDays) + .Where(a => a.Period.OverlapsWith(calendarItem.Period)) + .ToList(); + await ExecuteUIThread(() => { - foreach (var dayRange in DayRanges) + // Update existing items in-place where the item should remain + foreach (var calendarDay in currentDaysWithItem) { - foreach (var calendarDay in dayRange.CalendarDays) + if (targetDaysForItem.Contains(calendarDay)) { + // Item should stay in this day - update in-place + calendarDay.EventsCollection.UpdateCalendarItem(calendarItem); + } + else + { + // Item should no longer be in this day (time changed) - remove it var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItem.Id); if (existingItem != null) { @@ -921,36 +946,17 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } } } - }); - // Re-add to corresponding day ranges to render properly. - // Check if event falls into the current date range. - if (DayRanges.DisplayRange == null) return; - - // Get all periods from the visible day ranges - var visiblePeriods = DayRanges.Select(dr => dr.Period).ToList(); - - // For recurring events, expand them to check if any occurrences fall within visible periods - // For regular events, just check if they overlap with any period - var matchingItems = await _calendarService.GetExpandedRecurringEventsForPeriodsAsync(calendarItem, visiblePeriods); - - foreach (var item in matchingItems) - { - // Find the days that the event falls into - var allDaysForEvent = DayRanges - .SelectMany(a => a.CalendarDays) - .Where(a => a.Period.OverlapsWith(item.Period)); - - foreach (var calendarDay in allDaysForEvent) + // Add to new days where the item wasn't present before + foreach (var calendarDay in targetDaysForItem) { - var calendarItemViewModel = new CalendarItemViewModel(item); - - await ExecuteUIThread(() => + if (!currentDaysWithItem.Contains(calendarDay)) { + var calendarItemViewModel = new CalendarItemViewModel(calendarItem); calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); - }); + } } - } + }); FilterActiveCalendars(DayRanges); } @@ -960,6 +966,14 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, base.OnCalendarItemAdded(calendarItem); Debug.WriteLine($"Calendar item added: {calendarItem.Id}"); + // Series master events should not be visible on the UI. + // Their instances are already expanded and synced individually. + if (calendarItem.IsRecurringParent) + { + Debug.WriteLine($"Skipping series master event: {calendarItem.Title}"); + return; + } + // Check if event falls into the current date range. if (DayRanges.DisplayRange == null) return; @@ -1005,28 +1019,20 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, } // Get all periods from the visible day ranges - var visiblePeriods = DayRanges.Select(dr => dr.Period).ToList(); + // Note: Recurring event occurrences are now synced from server as individual instances + // No local expansion needed - just check if this item overlaps with visible periods + var allDaysForEvent = DayRanges + .SelectMany(a => a.CalendarDays) + .Where(a => a.Period.OverlapsWith(calendarItem.Period)); - // For recurring events, expand them to check if any occurrences fall within visible periods - // For regular events, just check if they overlap with any period - var matchingItems = await _calendarService.GetExpandedRecurringEventsForPeriodsAsync(calendarItem, visiblePeriods); - - foreach (var item in matchingItems) + foreach (var calendarDay in allDaysForEvent) { - // Find the days that the event falls into - var allDaysForEvent = DayRanges - .SelectMany(a => a.CalendarDays) - .Where(a => a.Period.OverlapsWith(item.Period)); + var calendarItemViewModel = new CalendarItemViewModel(calendarItem); - foreach (var calendarDay in allDaysForEvent) + await ExecuteUIThread(() => { - var calendarItemViewModel = new CalendarItemViewModel(item); - - await ExecuteUIThread(() => - { - calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); - }); - } + calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel); + }); } FilterActiveCalendars(DayRanges); diff --git a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs index 3508f968..07a386f6 100644 --- a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs +++ b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs @@ -2,8 +2,10 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using Itenso.TimePeriod; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; namespace Wino.Calendar.ViewModels.Data; @@ -89,6 +91,35 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC [ObservableProperty] public partial bool IsSelected { get; set; } + /// + /// The period of the day where this item is currently being displayed. + /// Used for multi-day event title formatting. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayTitle))] + public partial ITimePeriod DisplayingPeriod { get; set; } + + /// + /// Calendar settings for time formatting. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayTitle))] + public partial CalendarSettings CalendarSettings { get; set; } + + /// + /// Gets the display title based on the current displaying period. + /// + public string DisplayTitle + { + get + { + if (DisplayingPeriod == null || CalendarSettings == null) + return Title; + + return GetDisplayTitle(DisplayingPeriod, CalendarSettings); + } + } + public ObservableCollection Attendees { get; } = new ObservableCollection(); public CalendarItemViewModel(CalendarItem calendarItem) @@ -96,5 +127,82 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC CalendarItem = calendarItem; } + /// + /// Updates the underlying CalendarItem with new data and raises property change notifications. + /// + /// The updated calendar item data. + public void UpdateFrom(CalendarItem calendarItem) + { + if (calendarItem == null || calendarItem.Id != CalendarItem.Id) + return; + + // Update all mutable properties + CalendarItem.Title = calendarItem.Title; + CalendarItem.Description = calendarItem.Description; + CalendarItem.Location = calendarItem.Location; + CalendarItem.StartDate = calendarItem.StartDate; + CalendarItem.StartTimeZone = calendarItem.StartTimeZone; + CalendarItem.EndTimeZone = calendarItem.EndTimeZone; + CalendarItem.DurationInSeconds = calendarItem.DurationInSeconds; + CalendarItem.Recurrence = calendarItem.Recurrence; + CalendarItem.RecurringCalendarItemId = calendarItem.RecurringCalendarItemId; + CalendarItem.OrganizerDisplayName = calendarItem.OrganizerDisplayName; + CalendarItem.OrganizerEmail = calendarItem.OrganizerEmail; + CalendarItem.IsLocked = calendarItem.IsLocked; + CalendarItem.IsHidden = calendarItem.IsHidden; + CalendarItem.CustomEventColorHex = calendarItem.CustomEventColorHex; + CalendarItem.HtmlLink = calendarItem.HtmlLink; + CalendarItem.Status = calendarItem.Status; + CalendarItem.Visibility = calendarItem.Visibility; + CalendarItem.ShowAs = calendarItem.ShowAs; + CalendarItem.UpdatedAt = calendarItem.UpdatedAt; + CalendarItem.AssignedCalendar = calendarItem.AssignedCalendar; + + // Raise property changed for all bindable properties + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(StartDate)); + OnPropertyChanged(nameof(EndDate)); + OnPropertyChanged(nameof(DurationInSeconds)); + OnPropertyChanged(nameof(Period)); + OnPropertyChanged(nameof(IsAllDayEvent)); + OnPropertyChanged(nameof(IsMultiDayEvent)); + OnPropertyChanged(nameof(IsRecurringEvent)); + OnPropertyChanged(nameof(IsRecurringChild)); + OnPropertyChanged(nameof(IsRecurringParent)); + OnPropertyChanged(nameof(AssignedCalendar)); + OnPropertyChanged(nameof(DisplayTitle)); + } + + /// + /// Gets the display title for this calendar item when rendered in a specific day. + /// + public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings) + { + if (!IsMultiDayEvent) + return Title; + + var periodRelation = Period.GetRelation(displayingPeriod); + + if (periodRelation == PeriodRelation.StartInside || periodRelation == PeriodRelation.EnclosingStartTouching) + { + // Event starts within this day: "HH:mm -> Title" + return $"{calendarSettings.GetTimeString(StartDate.TimeOfDay)} -> {Title}"; + } + else if (periodRelation == PeriodRelation.EndInside || periodRelation == PeriodRelation.EnclosingEndTouching) + { + // Event ends within this day: "Title <- HH:mm" + return $"{Title} <- {calendarSettings.GetTimeString(EndDate.TimeOfDay)}"; + } + else if (periodRelation == PeriodRelation.Enclosing) + { + // Event spans the entire day + return $"{Translator.CalendarItemAllDay} {Title}"; + } + else + { + return Title; + } + } + public override string ToString() => CalendarItem.Title; } diff --git a/Wino.Core.Domain/Collections/CalendarEventCollection.cs b/Wino.Core.Domain/Collections/CalendarEventCollection.cs index 94a1eef6..720a3899 100644 --- a/Wino.Core.Domain/Collections/CalendarEventCollection.cs +++ b/Wino.Core.Domain/Collections/CalendarEventCollection.cs @@ -13,6 +13,7 @@ public class CalendarEventCollection { public event EventHandler CalendarItemAdded; public event EventHandler CalendarItemRemoved; + public event EventHandler CalendarItemUpdated; public event EventHandler CalendarItemsCleared; @@ -116,9 +117,13 @@ public class CalendarEventCollection private void AddCalendarItemInternal(ObservableRangeCollection collection, ICalendarItem calendarItem, bool create = true) { - if (calendarItem is not ICalendarItemViewModel) + if (calendarItem is not ICalendarItemViewModel viewModel) throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem)); + // Set the displaying context for proper title calculation + viewModel.DisplayingPeriod = Period; + viewModel.CalendarSettings = Settings; + collection.Add(calendarItem); if (create) @@ -144,6 +149,53 @@ public class CalendarEventCollection CalendarItemRemoved?.Invoke(this, calendarItem); } + /// + /// Updates an existing calendar item in-place. If the item's type changed (all-day vs regular), + /// it will be moved to the appropriate collection. + /// + /// The updated calendar item data. + /// True if the item was found and updated; false otherwise. + public bool UpdateCalendarItem(CalendarItem calendarItem) + { + var existingItem = _allItems.FirstOrDefault(x => x.Id == calendarItem.Id); + if (existingItem == null) + return false; + + // Get the collections this item is currently in (before update) + var oldCollections = GetProperCollectionsForCalendarItem(existingItem).ToList(); + + // Update the underlying data + if (existingItem is ICalendarItemViewModel viewModel) + { + viewModel.UpdateFrom(calendarItem); + } + + // Get the collections this item should be in (after update) + var newCollections = GetProperCollectionsForCalendarItem(existingItem).ToList(); + + // Check if the collections changed + var collectionsToRemoveFrom = oldCollections.Except(newCollections).ToList(); + var collectionsToAddTo = newCollections.Except(oldCollections).ToList(); + + // Remove from old collections that are no longer applicable + foreach (var collection in collectionsToRemoveFrom) + { + collection.Remove(existingItem); + } + + // Add to new collections that are now applicable + foreach (var collection in collectionsToAddTo) + { + if (!collection.Contains(existingItem)) + { + collection.Add(existingItem); + } + } + + CalendarItemUpdated?.Invoke(this, existingItem); + return true; + } + public void Clear() { _internalAllDayEvents.Clear(); diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs index 6768ae99..2bc2e652 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs @@ -4,6 +4,7 @@ using Itenso.TimePeriod; using SQLite; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; namespace Wino.Core.Domain.Entities.Calendar; @@ -72,9 +73,7 @@ public class CalendarItem : ICalendarItem } /// - /// 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. + /// Events that are child instances of a recurring event (occurrences or exceptions). /// public bool IsRecurringChild { @@ -85,7 +84,7 @@ public class CalendarItem : ICalendarItem } /// - /// Events that are either an exceptional instance of a recurring event or occurrences. + /// Events that are part of a recurring series (either as parent or child). /// public bool IsRecurringEvent => IsRecurringChild || IsRecurringParent; @@ -140,12 +139,12 @@ public class CalendarItem : ICalendarItem public string HtmlLink { get; set; } public CalendarItemStatus Status { get; set; } public CalendarItemVisibility Visibility { get; set; } - + /// /// Indicates how the event should be shown in the calendar (Free, Busy, Tentative, etc.). /// public CalendarItemShowAs ShowAs { get; set; } = CalendarItemShowAs.Busy; - + public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } public Guid CalendarId { get; set; } @@ -154,51 +153,11 @@ public class CalendarItem : ICalendarItem 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. + /// Id to load information related to this event (attendees, reminders, etc.). + /// For child events, if they have their own data, use their own Id. + /// For events that share data with their parent, return parent's Id. /// - [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, - StartTimeZone = StartTimeZone, - EndTimeZone = EndTimeZone, - RemoteEventId = RemoteEventId, - IsHidden = IsHidden, - IsLocked = IsLocked, - IsOccurrence = true - }; - } + public Guid EventTrackingId => Id; /// /// Gets the start date converted to user's local timezone for display. @@ -219,10 +178,10 @@ public class CalendarItem : ICalendarItem { var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone); var localTimeZone = TimeZoneInfo.Local; - + // Ensure DateTime is Unspecified kind before conversion var unspecifiedDateTime = DateTime.SpecifyKind(StartDate, DateTimeKind.Unspecified); - + // Convert from source timezone to local timezone return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone); } @@ -253,10 +212,10 @@ public class CalendarItem : ICalendarItem { var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone); var localTimeZone = TimeZoneInfo.Local; - + // Ensure DateTime is Unspecified kind before conversion var unspecifiedDateTime = DateTime.SpecifyKind(EndDate, DateTimeKind.Unspecified); - + // Convert from source timezone to local timezone return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone); } @@ -267,4 +226,6 @@ public class CalendarItem : ICalendarItem } } } + + public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings) => Period.ToString(); } diff --git a/Wino.Core.Domain/Interfaces/ICalendarItem.cs b/Wino.Core.Domain/Interfaces/ICalendarItem.cs index e83827a6..cfa0cb31 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.Models.Calendar; namespace Wino.Core.Domain.Interfaces; @@ -19,4 +20,13 @@ public interface ICalendarItem bool IsRecurringChild { get; } bool IsRecurringParent { get; } bool IsRecurringEvent { get; } + + /// + /// Gets the display title for this calendar item when rendered in a specific day. + /// For multi-day events, includes start/end time indicators. + /// + /// The period of the day where this item is being rendered. + /// Calendar settings for time formatting. + /// The formatted title string. + string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings); } diff --git a/Wino.Core.Domain/Interfaces/ICalendarItemViewModel.cs b/Wino.Core.Domain/Interfaces/ICalendarItemViewModel.cs index 3eaf1ff7..4600a3b2 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarItemViewModel.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarItemViewModel.cs @@ -1,4 +1,8 @@ -namespace Wino.Core.Domain.Interfaces; +using Itenso.TimePeriod; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Core.Domain.Interfaces; /// /// Temporarily to enforce CalendarItemViewModel. Used in CalendarEventCollection. @@ -6,4 +10,21 @@ public interface ICalendarItemViewModel { bool IsSelected { get; set; } + + /// + /// The period of the day where this item is currently being displayed. + /// + ITimePeriod DisplayingPeriod { get; set; } + + /// + /// Calendar settings for time formatting. + /// + CalendarSettings CalendarSettings { get; set; } + + /// + /// Updates the view model's underlying CalendarItem from new data. + /// This allows in-place updates without removing and re-adding items. + /// + /// The updated calendar item data. + void UpdateFrom(CalendarItem calendarItem); } diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs index 694ea7a7..762c9649 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs @@ -24,16 +24,8 @@ public interface ICalendarService /// /// The calendar to retrieve events from. /// The time period to query events for. - /// List of calendar items including regular events and recurring event occurrences. + /// List of calendar items that fall within the requested period. Task> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period); - - /// - /// Expands a recurring calendar item to check if any of its occurrences fall within the given periods. - /// - /// The calendar item to expand (can be recurring or non-recurring). - /// The list of periods to check against. - /// List of calendar items (either the original item or expanded recurrence instances) that fall within the periods. - Task> GetExpandedRecurringEventsForPeriodsAsync(CalendarItem calendarItem, IEnumerable periods); Task GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId); Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken); diff --git a/Wino.Core.Tests/Services/CalendarServiceTests.cs b/Wino.Core.Tests/Services/CalendarServiceTests.cs index 8da2bfcb..d8dd3575 100644 --- a/Wino.Core.Tests/Services/CalendarServiceTests.cs +++ b/Wino.Core.Tests/Services/CalendarServiceTests.cs @@ -1,8 +1,6 @@ using FluentAssertions; using Itenso.TimePeriod; -using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; -using Wino.Core.Domain.Interfaces; using Wino.Core.Tests.Helpers; using Wino.Services; using Xunit; @@ -10,8 +8,9 @@ using Xunit; namespace Wino.Core.Tests.Services; /// -/// Tests for CalendarService, focusing on the GetCalendarEventsAsync method -/// which handles both regular and recurring events with RFC 5545 patterns. +/// Tests for CalendarService, focusing on the GetCalendarEventsAsync method. +/// Note: Recurring event occurrences are now synced from the server as individual instances, +/// not calculated locally from recurrence patterns. /// public class CalendarServiceTests : IAsyncLifetime { @@ -119,185 +118,76 @@ public class CalendarServiceTests : IAsyncLifetime } [Fact] - public async Task GetCalendarEventsAsync_WithDailyRecurringEvent_ReturnsMultipleOccurrences() + public async Task GetCalendarEventsAsync_WithRecurringEventInstances_ReturnsAllInstancesInPeriod() { - // Arrange - Create a daily recurring event starting Jan 15, 2025 - var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); - var recurringEvent = new CalendarItem + // Arrange - Simulate synced recurring event instances (as they would come from server) + var parentId = Guid.NewGuid(); + var startDate1 = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); + var startDate2 = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc); + var startDate3 = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc); + + // Parent series master (typically hidden or outside display range) + var parentEvent = new CalendarItem { - Id = Guid.NewGuid(), + Id = parentId, Title = "Daily Standup", Description = "Daily team sync", - StartDate = startDate, + StartDate = startDate1, DurationInSeconds = 1800, // 30 minutes CalendarId = _testCalendar.Id, IsHidden = false, - // Daily recurrence pattern (RFC 5545) - Recurrence = "RRULE:FREQ=DAILY;COUNT=5" + Recurrence = "RRULE:FREQ=DAILY;COUNT=3" }; - await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null); - - // Query for the week containing the recurring events - var period = new TimeRange( - new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc)); - - // Act - var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - - // Assert - result.Should().HaveCount(5, "because the event recurs daily for 5 days"); - result.Should().AllSatisfy(e => - { - e.Title.Should().Be("Daily Standup"); - e.DurationInSeconds.Should().Be(1800); - e.IsRecurringChild.Should().BeTrue(); - e.IsOccurrence.Should().BeTrue(); - }); - - // Verify the dates are sequential - var dates = result.Select(e => e.StartDate.Date).OrderBy(d => d).ToList(); - dates.Should().HaveCount(5); - dates[0].Should().Be(new DateTime(2025, 1, 15).Date); - dates[1].Should().Be(new DateTime(2025, 1, 16).Date); - dates[2].Should().Be(new DateTime(2025, 1, 17).Date); - dates[3].Should().Be(new DateTime(2025, 1, 18).Date); - dates[4].Should().Be(new DateTime(2025, 1, 19).Date); - } - - [Fact] - public async Task GetCalendarEventsAsync_WithWeeklyRecurringEvent_ReturnsCorrectOccurrences() - { - // Arrange - Create a weekly recurring event on Mondays - var startDate = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc); // Monday - var recurringEvent = new CalendarItem + // Individual occurrence instances (as synced from server) + var instance1 = new CalendarItem { Id = Guid.NewGuid(), - Title = "Weekly Review", - StartDate = startDate, - DurationInSeconds = 3600, // 1 hour - CalendarId = _testCalendar.Id, - IsHidden = false, - // Weekly recurrence on Mondays - Recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4" - }; - - await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null); - - // Query for a month - var period = new TimeRange( - new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc)); - - // Act - var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - - // Assert - result.Should().HaveCount(4, "because the event recurs weekly for 4 weeks"); - result.Should().AllSatisfy(e => - { - e.Title.Should().Be("Weekly Review"); - e.StartDate.DayOfWeek.Should().Be(DayOfWeek.Monday); - }); - } - - [Fact] - public async Task GetCalendarEventsAsync_WithRecurringEventAndException_ExcludesException() - { - // Arrange - Create a daily recurring event - var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); - var recurringEvent = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Meeting", - StartDate = startDate, + Title = "Daily Standup", + StartDate = startDate1, DurationInSeconds = 1800, CalendarId = _testCalendar.Id, - IsHidden = false, - Recurrence = "RRULE:FREQ=DAILY;COUNT=5" - }; - - await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null); - - // Create an exception instance for Jan 17 (cancelled) - var exceptionInstance = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Meeting (Cancelled)", - StartDate = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc), - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - RecurringCalendarItemId = recurringEvent.Id, - IsHidden = true // Cancelled/hidden - }; - - await _calendarService.CreateNewCalendarItemAsync(exceptionInstance, null); - - var period = new TimeRange( - new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc)); - - // Act - var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - - // Assert - result.Should().HaveCount(4, "because one occurrence is cancelled/hidden"); - result.Should().NotContain(e => e.StartDate.Date == new DateTime(2025, 1, 17).Date); - } - - [Fact] - public async Task GetCalendarEventsAsync_WithRecurringEventAndModifiedException_ReturnsModifiedVersion() - { - // Arrange - Create a daily recurring event - var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); - var recurringEvent = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Meeting", - StartDate = startDate, - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - IsHidden = false, - Recurrence = "RRULE:FREQ=DAILY;COUNT=5" - }; - - await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null); - - // Create a modified exception instance for Jan 17 (time and duration changed) - // The exception starts at 10:00 just like the original occurrence - var modifiedException = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Meeting (Rescheduled)", - StartDate = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc), // Same time, different properties - DurationInSeconds = 3600, // Different duration (1 hour instead of 30 min) - CalendarId = _testCalendar.Id, - RecurringCalendarItemId = recurringEvent.Id, + RecurringCalendarItemId = parentId, IsHidden = false }; - await _calendarService.CreateNewCalendarItemAsync(modifiedException, null); + var instance2 = new CalendarItem + { + Id = Guid.NewGuid(), + Title = "Daily Standup", + StartDate = startDate2, + DurationInSeconds = 1800, + CalendarId = _testCalendar.Id, + RecurringCalendarItemId = parentId, + IsHidden = false + }; + + var instance3 = new CalendarItem + { + Id = Guid.NewGuid(), + Title = "Daily Standup", + StartDate = startDate3, + DurationInSeconds = 1800, + CalendarId = _testCalendar.Id, + RecurringCalendarItemId = parentId, + IsHidden = false + }; + + await _calendarService.CreateNewCalendarItemAsync(parentEvent, null); + await _calendarService.CreateNewCalendarItemAsync(instance1, null); + await _calendarService.CreateNewCalendarItemAsync(instance2, null); + await _calendarService.CreateNewCalendarItemAsync(instance3, null); var period = new TimeRange( new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc)); + new DateTime(2025, 1, 18, 0, 0, 0, DateTimeKind.Utc)); // Act var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - // Assert - result.Should().HaveCount(5, "4 normal occurrences + 1 modified exception"); - - // Check the modified exception - it should have the updated duration - var jan17Events = result.Where(e => e.StartDate.Date == new DateTime(2025, 1, 17).Date).ToList(); - jan17Events.Should().HaveCount(1, "only the modified exception should appear for Jan 17"); - - var modifiedEvent = jan17Events.First(); - modifiedEvent.Title.Should().Be("Daily Meeting (Rescheduled)"); - modifiedEvent.DurationInSeconds.Should().Be(3600); - modifiedEvent.IsRecurringChild.Should().BeTrue(); - modifiedEvent.IsOccurrence.Should().BeFalse(); + // Assert - Should return parent + 3 instances = 4 total + result.Should().HaveCount(4, "parent event plus 3 instances"); + result.Where(e => e.Title == "Daily Standup").Should().HaveCount(4); } [Fact] @@ -415,163 +305,46 @@ public class CalendarServiceTests : IAsyncLifetime } [Fact] - public async Task GetCalendarEventsAsync_WithRecurringEventWithUNTIL_StopsAfterUntilDate() + public async Task GetCalendarEventsAsync_WithRecurringChildEvent_ReturnsChildAsRecurringChild() { - // Arrange - Create two weekly recurring events with same pattern - // Event 1: Has UNTIL date of Nov 13, 2025 (should stop after this date) - // Event 2: No UNTIL date (continues indefinitely) - - var startDate = new DateTime(2025, 10, 10, 14, 0, 0, DateTimeKind.Utc); // Friday, Oct 10, 2025 - - // Event with UNTIL - should stop on Nov 13, 2025 - var eventWithUntil = new CalendarItem + // Arrange - Create a parent and child event + var parentId = Guid.NewGuid(); + var parentEvent = new CalendarItem { - Id = Guid.NewGuid(), - RemoteEventId = "event-with-until-123", - Title = "Weekly Meeting (Until Nov 13)", - StartDate = startDate, - DurationInSeconds = 3600, // 1 hour + Id = parentId, + Title = "Parent Recurring Event", + StartDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc), + DurationInSeconds = 3600, CalendarId = _testCalendar.Id, IsHidden = false, - // Weekly on Fridays, until November 13, 2025 - Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20251113" + Recurrence = "RRULE:FREQ=DAILY" }; - // Event without UNTIL - continues indefinitely - var eventWithoutUntil = new CalendarItem + var childEvent = new CalendarItem { Id = Guid.NewGuid(), - RemoteEventId = "event-without-until-456", - Title = "Weekly Meeting (No End)", - StartDate = startDate, - DurationInSeconds = 3600, // 1 hour + Title = "Occurrence Instance", + StartDate = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc), + DurationInSeconds = 3600, CalendarId = _testCalendar.Id, - IsHidden = false, - // Weekly on Fridays, no end date - Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR" + RecurringCalendarItemId = parentId, + IsHidden = false }; - await _calendarService.CreateNewCalendarItemAsync(eventWithUntil, null); - await _calendarService.CreateNewCalendarItemAsync(eventWithoutUntil, null); + await _calendarService.CreateNewCalendarItemAsync(parentEvent, null); + await _calendarService.CreateNewCalendarItemAsync(childEvent, null); - // Query for a period AFTER the UNTIL date (Nov 20 - Nov 30, 2025) - // This is past November 13, so only the event without UNTIL should appear - var periodAfterUntil = new TimeRange( - new DateTime(2025, 11, 20, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 11, 30, 0, 0, 0, DateTimeKind.Utc)); + var period = new TimeRange( + new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 1, 17, 0, 0, 0, DateTimeKind.Utc)); // Act - var resultAfterUntil = await _calendarService.GetCalendarEventsAsync(_testCalendar, periodAfterUntil); + var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - // Assert - Only the event without UNTIL should appear - // In Nov 20-30 period, there are 2 Fridays: Nov 21 and Nov 28 - // Both should only be from the event WITHOUT UNTIL - resultAfterUntil.Should().HaveCount(2, "there are 2 Fridays in Nov 20-30 period"); - resultAfterUntil.Should().AllSatisfy(e => - { - e.Title.Should().Be("Weekly Meeting (No End)"); - e.RecurringCalendarItemId.Should().Be(eventWithoutUntil.Id); - }); - - // Verify NO occurrences from the event with UNTIL appear after the UNTIL date - var withUntilOccurrences = resultAfterUntil.Where(e => e.RecurringCalendarItemId == eventWithUntil.Id).ToList(); - withUntilOccurrences.Should().BeEmpty("the event with UNTIL=Nov 13 should not appear after that date"); - - // Query for a period BEFORE the UNTIL date (Oct 10 - Nov 10, 2025) - // Both events should appear since we're before the UNTIL date - var periodBeforeUntil = new TimeRange( - new DateTime(2025, 10, 10, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 11, 10, 0, 0, 0, DateTimeKind.Utc)); - - var resultBeforeUntil = await _calendarService.GetCalendarEventsAsync(_testCalendar, periodBeforeUntil); - - // Should have occurrences from both events - // From Oct 10 to Nov 10, Fridays are: Oct 10, 17, 24, 31, Nov 7 - // That's 5 Fridays, so we expect 10 total (5 from each event) - resultBeforeUntil.Should().HaveCount(10, "both events should have 5 occurrences each in this period"); - - var untilEventOccurrences = resultBeforeUntil.Where(e => e.RecurringCalendarItemId == eventWithUntil.Id).ToList(); - var noUntilEventOccurrences = resultBeforeUntil.Where(e => e.RecurringCalendarItemId == eventWithoutUntil.Id).ToList(); - - untilEventOccurrences.Should().HaveCount(5); - noUntilEventOccurrences.Should().HaveCount(5); - } - - [Fact] - public async Task GetCalendarEventsAsync_WithDuplicateRecurringEvents_OnlyShowsNonExpiredOccurrences() - { - // Arrange - Simulates the scenario where you have the same recurring event - // synced twice with different RemoteEventIds, one with UNTIL and one without - - var startDate = new DateTime(2025, 10, 10, 14, 0, 0, DateTimeKind.Utc); // Friday, Oct 10, 2025 - - // First sync: Event with UNTIL (older version that expired) - var expiredEvent = new CalendarItem - { - Id = Guid.NewGuid(), - RemoteEventId = "recurring-event-v1", - Title = "Team Standup", - StartDate = startDate, - DurationInSeconds = 1800, // 30 min - CalendarId = _testCalendar.Id, - IsHidden = false, - Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20251113T000000Z" - }; - - // Second sync: Same event but without UNTIL (updated version) - var activeEvent = new CalendarItem - { - Id = Guid.NewGuid(), - RemoteEventId = "recurring-event-v2", - Title = "Team Standup", - StartDate = startDate, - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - IsHidden = false, - Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR" // No UNTIL - continues indefinitely - }; - - await _calendarService.CreateNewCalendarItemAsync(expiredEvent, null); - await _calendarService.CreateNewCalendarItemAsync(activeEvent, null); - - // Query for December 2025 (well after the UNTIL date of Nov 13) - var decemberPeriod = new TimeRange( - new DateTime(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)); - - // Act - var decemberResults = await _calendarService.GetCalendarEventsAsync(_testCalendar, decemberPeriod); - - // Assert - Should only see occurrences from the active event (without UNTIL) - // December 2025 has Fridays on: 5, 12, 19, 26 - decemberResults.Should().HaveCount(4, "December has 4 Fridays"); - decemberResults.Should().AllSatisfy(e => - { - e.RecurringCalendarItemId.Should().Be(activeEvent.Id, "only the event without UNTIL should appear"); - e.Title.Should().Be("Team Standup"); - }); - - // Verify the expired event doesn't contribute any occurrences - var expiredOccurrences = decemberResults.Where(e => e.RecurringCalendarItemId == expiredEvent.Id).ToList(); - expiredOccurrences.Should().BeEmpty("the expired event with UNTIL=Nov 13 should not generate occurrences in December"); - - // Also test a period that spans the UNTIL boundary (November 1-30) - var novemberPeriod = new TimeRange( - new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 11, 30, 23, 59, 59, DateTimeKind.Utc)); - - var novemberResults = await _calendarService.GetCalendarEventsAsync(_testCalendar, novemberPeriod); - - // November 2025 Fridays: 7, 14, 21, 28 - // Event with UNTIL stops on Nov 13, so Nov 7 is the last occurrence for that one - // Event without UNTIL continues, so it has all 4 occurrences - - var expiredEventInNov = novemberResults.Where(e => e.RecurringCalendarItemId == expiredEvent.Id).ToList(); - var activeEventInNov = novemberResults.Where(e => e.RecurringCalendarItemId == activeEvent.Id).ToList(); - - expiredEventInNov.Should().HaveCount(1, "expired event only appears on Nov 7 (before UNTIL=20251113)"); - expiredEventInNov[0].StartDate.Day.Should().Be(7); - - activeEventInNov.Should().HaveCount(4, "active event appears on all 4 Fridays"); + // Assert + result.Should().HaveCount(1); + result[0].Title.Should().Be("Occurrence Instance"); + result[0].IsRecurringChild.Should().BeTrue(); + result[0].RecurringCalendarItemId.Should().Be(parentId); } } diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index d42eec75..697eaa45 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -161,6 +161,9 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso // Hide canceled events. calendarItem.IsHidden = calendarItem.Status == CalendarItemStatus.Cancelled; + // Set assigned calendar for navigation properties to work. + calendarItem.AssignedCalendar = assignedCalendar; + // Manage the recurring event id. if (parentRecurringEvent != null) { diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 498f21d7..7b7008a8 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -42,11 +42,8 @@ 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; + // All event types are now handled: SingleInstance, SeriesMaster, Occurrence, and Exception. + // Occurrences from CalendarView are individual instances that are saved separately. var savingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id); @@ -81,10 +78,12 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, savingItem.Description = calendarEvent.Body?.Content; savingItem.Location = calendarEvent.Location?.DisplayName; - if (calendarEvent.Type == EventType.Exception && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId)) + // Handle recurring event relationships for both Exception and Occurrence types + if ((calendarEvent.Type == EventType.Exception || calendarEvent.Type == EventType.Occurrence) + && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId)) { - // This is a recurring event exception. - // We need to find the parent event and set it as recurring event id. + // This is a recurring event instance (either an exception or a regular occurrence). + // Link it to the parent series master. var parentEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterId); @@ -94,12 +93,14 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, } else { - Log.Warning($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}"); - return; + // Parent not found yet - this can happen if occurrences sync before the series master. + // We still save the event but without the parent link for now. + Log.Warning($"Parent recurring event (SeriesMasterId: {calendarEvent.SeriesMasterId}) not found for event {calendarEvent.Id}. Event will be saved without parent link."); } } // Convert the recurrence pattern to string for parent recurring events. + // Note: We store this for reference but don't use it to calculate occurrences. if (calendarEvent.Type == EventType.SeriesMaster && calendarEvent.Recurrence != null) { savingItem.Recurrence = OutlookIntegratorExtensions.ToRfc5545RecurrenceString(calendarEvent.Recurrence); @@ -234,6 +235,9 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, .ToList(); } + // Set assigned calendar for navigation properties to work. + savingItem.AssignedCalendar = assignedCalendar; + // Use CalendarService to create or update the event if (isNewItem) { diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 17483356..d8a02bd4 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -392,7 +392,10 @@ public class GmailSynchronizer : WinoSynchronizer(updateRequest, request)]; } + public override List> DeleteCalendarEvent(DeleteCalendarEventRequest request) + { + var calendarItem = request.Item; + + // Get the calendar for this event + var calendar = calendarItem.AssignedCalendar; + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot delete event without remote event ID"); + } + + // Delete the event using Google Calendar API + var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, calendarItem.RemoteEventId); + + // Send cancellation notifications to attendees + deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All; + + return [new HttpRequestBundle(deleteRequest, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 311bfe28..5fe46078 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1811,14 +1811,8 @@ public class OutlookSynchronizer : WinoSynchronizer.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) => { - // Skip occurrence events during initial sync - only sync master recurring events and single instances - // Occurrences are individual instances of recurring events and will be generated from the seriesMaster - if (item.Type == Microsoft.Graph.Models.EventType.Occurrence) - { - _logger.Debug("Skipping occurrence event {EventId} during initial sync", item.Id); - return true; // Skip this occurrence - } - + // Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception + // CalendarView already expands recurring events into individual occurrences events.Add(item); return true; @@ -1835,10 +1829,6 @@ public class OutlookSynchronizer : WinoSynchronizer(updateRequest, request)]; } + public override List> DeleteCalendarEvent(DeleteCalendarEventRequest request) + { + var calendarItem = request.Item; + + // Get the calendar for this event + var calendar = calendarItem.AssignedCalendar; + if (calendar == null) + { + throw new InvalidOperationException("Calendar item must have an assigned calendar"); + } + + if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) + { + throw new InvalidOperationException("Cannot delete event without remote event ID"); + } + + // Delete the event using Graph API + var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].ToDeleteRequestInformation(); + + return [new HttpRequestBundle(deleteRequest, request)]; + } + #endregion public override async Task KillSynchronizerAsync() diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index 851d6b85..432ac1e6 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -384,7 +384,7 @@ public abstract class WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> UpdateCalendarEvent(UpdateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> DeleteCalendarEvent(DeleteCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml index 13a42dc2..0001c2ae 100644 --- a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml @@ -21,7 +21,7 @@ DoubleTapped="ControlDoubleTapped" RightTapped="ControlRightTapped" Tapped="ControlTapped" - ToolTipService.ToolTip="{x:Bind CalendarItemTitle, Mode=OneWay}"> + ToolTipService.ToolTip="{x:Bind CalendarItem.DisplayTitle, Mode=OneWay}"> @@ -57,7 +57,7 @@ FontSize="13" Foreground="{x:Bind helpers:XamlHelpers.GetReadableTextColor(CalendarItem.AssignedCalendar.BackgroundColorHex), Mode=OneWay}" HorizontalTextAlignment="Center" - Text="{x:Bind CalendarItemTitle, Mode=OneWay}" + Text="{x:Bind CalendarItem.DisplayTitle, Mode=OneWay}" TextTrimming="CharacterEllipsis" /> diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs index cd4d168b..e9cbe122 100644 --- a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs +++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml.cs @@ -21,8 +21,6 @@ public sealed partial class CalendarItemControl : UserControl public static readonly DependencyProperty CalendarItemProperty = DependencyProperty.Register(nameof(CalendarItem), typeof(CalendarItemViewModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnCalendarItemChanged))); public static readonly DependencyProperty IsDraggingProperty = DependencyProperty.Register(nameof(IsDragging), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false)); public static readonly DependencyProperty IsCustomEventAreaProperty = DependencyProperty.Register(nameof(IsCustomEventArea), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false)); - public static readonly DependencyProperty CalendarItemTitleProperty = DependencyProperty.Register(nameof(CalendarItemTitle), typeof(string), typeof(CalendarItemControl), new PropertyMetadata(string.Empty)); - public static readonly DependencyProperty DisplayingDateProperty = DependencyProperty.Register(nameof(DisplayingDate), typeof(CalendarDayModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnDisplayDateChanged))); /// /// Whether the control is displaying as regular event or all-multi day area in the day control. @@ -33,22 +31,6 @@ public sealed partial class CalendarItemControl : UserControl set { SetValue(IsCustomEventAreaProperty, value); } } - /// - /// Day that the calendar item is rendered at. - /// It's needed for title manipulation and some other adjustments later on. - /// - public CalendarDayModel DisplayingDate - { - get { return (CalendarDayModel)GetValue(DisplayingDateProperty); } - set { SetValue(DisplayingDateProperty, value); } - } - - public string CalendarItemTitle - { - get { return (string)GetValue(CalendarItemTitleProperty); } - set { SetValue(CalendarItemTitleProperty, value); } - } - public CalendarItemViewModel CalendarItem { get { return (CalendarItemViewModel)GetValue(CalendarItemProperty); } @@ -66,77 +48,14 @@ public sealed partial class CalendarItemControl : UserControl InitializeComponent(); } - private static void OnDisplayDateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - if (d is CalendarItemControl control) - { - control.UpdateControlVisuals(); - } - } - private static void OnCalendarItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is CalendarItemControl control) { - control.UpdateControlVisuals(); + control.UpdateVisualStates(); } } - private void UpdateControlVisuals() - { - // Depending on the calendar item's duration and attributes, we might need to change the display title. - // 1. Multi-Day events should display the start date and end date. - // 2. Multi-Day events that occupy the whole day just shows 'all day'. - // 3. Other events should display the title. - - if (CalendarItem == null) return; - if (DisplayingDate == null) return; - - if (CalendarItem.IsMultiDayEvent) - { - // Multi day events are divided into 3 categories: - // 1. All day events - // 2. Events that started after the period. - // 3. Events that started before the period and finishes within the period. - - var periodRelation = CalendarItem.Period.GetRelation(DisplayingDate.Period); - - if (periodRelation == Itenso.TimePeriod.PeriodRelation.StartInside || - periodRelation == PeriodRelation.EnclosingStartTouching) - { - // hour -> title - CalendarItemTitle = $"{DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.StartDate.TimeOfDay)} -> {CalendarItem.Title}"; - } - else if ( - periodRelation == PeriodRelation.EndInside || - periodRelation == PeriodRelation.EnclosingEndTouching) - { - // title <- hour - CalendarItemTitle = $"{CalendarItem.Title} <- {DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.EndDate.TimeOfDay)}"; - } - else if (periodRelation == PeriodRelation.Enclosing) - { - // This event goes all day and it's multi-day. - // Item must be hidden in the calendar but displayed on the custom area at the top. - - CalendarItemTitle = $"{Translator.CalendarItemAllDay} {CalendarItem.Title}"; - } - else - { - // Not expected, but there it is. - CalendarItemTitle = CalendarItem.Title; - } - - // Debug.WriteLine($"{CalendarItem.Title} Period relation with {DisplayingDate.Period.ToString()}: {periodRelation}"); - } - else - { - CalendarItemTitle = CalendarItem.Title; - } - - UpdateVisualStates(); - } - private void UpdateVisualStates() { if (CalendarItem == null) return; @@ -175,9 +94,9 @@ public sealed partial class CalendarItemControl : UserControl await Task.Delay(100); - if (isSingleTap) + if (isSingleTap && CalendarItem != null) { - WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, DisplayingDate)); + WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, null)); } } diff --git a/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs b/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs index 8336901d..a1a6c186 100644 --- a/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs +++ b/Wino.Mail.WinUI/Services/MailAuthenticatorConfiguration.cs @@ -14,7 +14,12 @@ public class MailAuthenticatorConfiguration : IAuthenticatorConfig "mail.send", "Mail.Send.Shared", "Mail.ReadWrite.Shared", - "User.Read" + "User.Read", + "Calendars.ReadBasic", + "Calendars.ReadWrite", + "Calendars.ReadWrite.Shared", + "Calendars.Read", + "Calendars.Read.Shared", ]; public string GmailAuthenticatorClientId => "973025879644-s7b4ur9p3rlgop6a22u7iuptdc0brnrn.apps.googleusercontent.com"; @@ -24,7 +29,10 @@ public class MailAuthenticatorConfiguration : IAuthenticatorConfig "https://mail.google.com/", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/gmail.labels", - "https://www.googleapis.com/auth/userinfo.email" + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar.settings.readonly", ]; public string GmailTokenStoreIdentifier => "WinoMailGmailTokenStore"; diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml index 2bf25250..b9a6f657 100644 --- a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml +++ b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml @@ -31,10 +31,7 @@ - + @@ -44,7 +41,8 @@ - + + @@ -63,7 +61,6 @@ - @@ -72,6 +69,7 @@ + - - + - - + - + @@ -382,10 +372,7 @@ - + diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index a8d8cee9..bd463f24 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -2,14 +2,10 @@ 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 Itenso.TimePeriod; using Serilog; -using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -30,6 +26,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService public int[] GetPredefinedReminderMinutes() => PredefinedReminderMinutes; + /// + /// Loads the AssignedCalendar (and its MailAccount) for a CalendarItem if not already loaded. + /// + private async Task LoadAssignedCalendarAsync(CalendarItem calendarItem) + { + if (calendarItem == null || calendarItem.AssignedCalendar != null) return; + + calendarItem.AssignedCalendar = await Connection.GetAsync(calendarItem.CalendarId); + if (calendarItem.AssignedCalendar != null) + { + calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync(calendarItem.AssignedCalendar.AccountId); + } + } + public Task> GetAccountCalendarsAsync(Guid accountId) => Connection.Table().Where(x => x.AccountId == accountId).OrderByDescending(a => a.IsPrimary).ToListAsync(); @@ -142,16 +152,16 @@ public class CalendarService : BaseDatabaseService, ICalendarService /// /// Retrieves calendar events for a given calendar within the specified time period. - /// This includes regular events and expanded recurring event occurrences based on RFC 5545 patterns. + /// Returns all events (single instances and recurring event occurrences) that overlap with the period. + /// Note: Recurring events are expected to be synced as individual instances from the server. + /// Series master events (parents) are filtered out as they should not be displayed directly. /// /// The calendar to retrieve events from. /// The time period to query events for. - /// List of calendar items including regular events and recurring event occurrences. + /// List of calendar items that fall within the requested period. public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period) { - // TODO: Implement caching strategy for better performance with large event sets. - // Consider using a cache keyed by calendar ID and time period. - + // Fetch all non-hidden events for this calendar var accountEvents = await Connection.Table() .Where(x => x.CalendarId == calendar.Id && !x.IsHidden) .ToListAsync(); @@ -160,95 +170,18 @@ public class CalendarService : BaseDatabaseService, ICalendarService foreach (var calendarItem in accountEvents) { + // Skip series master events - they should not be displayed directly. + // Individual instances are synced from the server and displayed instead. + if (calendarItem.IsRecurringParent) + continue; + calendarItem.AssignedCalendar = calendar; - // Skip exception instances - they will be handled by their parent recurring event - if (calendarItem.RecurringCalendarItemId.HasValue) + // Check if the event overlaps with the requested period + if (calendarItem.Period.OverlapsWith(period)) { - continue; + result.Add(calendarItem); } - - if (string.IsNullOrEmpty(calendarItem.Recurrence)) - { - // Regular non-recurring event - simply check if it overlaps with the requested period. - if (calendarItem.Period.OverlapsWith(period)) - { - result.Add(calendarItem); - } - } - else - { - // Recurring event - expand occurrences within the period. - // Wino stores recurring events as a series master with RFC 5545 recurrence rules. - // Exception instances (modified or cancelled) are stored separately and linked via RecurringCalendarItemId. - var expandedOccurrences = await ExpandRecurringEventAsync(calendarItem, period); - result.AddRange(expandedOccurrences); - } - } - - return result; - } - - /// - /// Expands a recurring event into its occurrences within the specified period. - /// Handles exception instances (modified or cancelled occurrences) by excluding them from the expansion. - /// - /// The recurring event series master. - /// The time period to expand occurrences within. - /// List of calendar items representing individual occurrences in the period. - private async Task> ExpandRecurringEventAsync(CalendarItem recurringEvent, ITimePeriod period) - { - var result = new List(); - - // Parse the RFC 5545 recurrence pattern. - var calendarEvent = new CalendarEvent - { - Start = new CalDateTime(recurringEvent.StartDate), - End = new CalDateTime(recurringEvent.EndDate), - }; - - var recurrenceLines = Regex.Split(recurringEvent.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator); - foreach (var line in recurrenceLines) - { - calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line)); - } - - // Calculate all occurrences in the requested period using iCal.NET. - var occurrences = calendarEvent.GetOccurrences(period.Start, period.End); - - // Retrieve exception instances (modified or cancelled occurrences). - // These are stored as separate CalendarItem records with RecurringCalendarItemId set. - var exceptionInstances = await Connection.Table() - .Where(a => a.RecurringCalendarItemId == recurringEvent.Id) - .ToListAsync() - .ConfigureAwait(false); - - foreach (var occurrence in occurrences) - { - // Check if this occurrence has been modified/cancelled (exception instance exists). - // Compare by checking if an exception instance overlaps with this occurrence's time window. - var occurrenceStart = occurrence.Period.StartTime.Value; - var occurrenceEnd = occurrence.Period.EndTime?.Value ?? occurrenceStart.Add(occurrence.Period.Duration); - - var exceptionInstance = exceptionInstances.FirstOrDefault(a => - a.StartDate <= occurrenceEnd && a.EndDate >= occurrenceStart); - - if (exceptionInstance == null) - { - // No exception - create a virtual occurrence from the series master. - var occurrenceItem = recurringEvent.CreateRecurrence( - occurrenceStart, - occurrence.Period.Duration.TotalSeconds); - - result.Add(occurrenceItem); - } - else if (!exceptionInstance.IsHidden && exceptionInstance.Period.OverlapsWith(period)) - { - // Exception exists and is not hidden - include the modified version. - exceptionInstance.AssignedCalendar = recurringEvent.AssignedCalendar; - result.Add(exceptionInstance); - } - // If exception is hidden, skip this occurrence entirely. } return result; @@ -270,15 +203,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService "SELECT * FROM CalendarItem WHERE Id = ?", id); - // Load assigned calendar and account. - if (calendarItem != null) - { - calendarItem.AssignedCalendar = await Connection.GetAsync(calendarItem.CalendarId); - if (calendarItem.AssignedCalendar != null) - { - calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync(calendarItem.AssignedCalendar.AccountId); - } - } + await LoadAssignedCalendarAsync(calendarItem); return calendarItem; } @@ -289,15 +214,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService "SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?", accountCalendarId, remoteEventId); - // Load assigned calendar and account. - if (calendarItem != null) - { - calendarItem.AssignedCalendar = await Connection.GetAsync(calendarItem.CalendarId); - if (calendarItem.AssignedCalendar != null) - { - calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync(calendarItem.AssignedCalendar.AccountId); - } - } + await LoadAssignedCalendarAsync(calendarItem); return calendarItem; } @@ -310,72 +227,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService } /// - /// Expands a recurring calendar item to check if any of its occurrences fall within the given periods. - /// For non-recurring events, returns the item if it overlaps with any period. - /// For recurring events, expands occurrences and returns those that fall within any of the periods. - /// - /// The calendar item to expand (can be recurring or non-recurring). - /// The list of periods to check against. - /// List of calendar items (either the original item or expanded recurrence instances) that fall within the periods. - public async Task> GetExpandedRecurringEventsForPeriodsAsync(CalendarItem calendarItem, IEnumerable periods) - { - var result = new List(); - - if (calendarItem == null || periods == null || !periods.Any()) - { - return result; - } - - // Ensure AssignedCalendar is loaded - if (calendarItem.AssignedCalendar == null) - { - calendarItem.AssignedCalendar = await GetAccountCalendarAsync(calendarItem.CalendarId); - } - - // For non-recurring events, check if it overlaps with any of the provided periods - if (string.IsNullOrEmpty(calendarItem.Recurrence)) - { - foreach (var period in periods) - { - if (calendarItem.Period.OverlapsWith(period)) - { - result.Add(calendarItem); - break; // Add it only once - } - } - } - else - { - // For recurring events, expand occurrences for the combined date range of all periods - // Find the minimum and maximum dates across all periods - var minDate = periods.Min(p => p.Start); - var maxDate = periods.Max(p => p.End); - - var combinedPeriod = new TimeRange(minDate, maxDate); - - // Expand the recurring event for the combined period - var expandedOccurrences = await ExpandRecurringEventAsync(calendarItem, combinedPeriod); - - // Filter occurrences that fall within any of the individual periods - foreach (var occurrence in expandedOccurrences) - { - foreach (var period in periods) - { - if (occurrence.Period.OverlapsWith(period)) - { - result.Add(occurrence); - break; // Add it only once even if it overlaps with multiple periods - } - } - } - } - - return result; - } - - /// - /// Gets attendees for a calendar item. For recurring event occurrences, - /// callers should pass the EventTrackingId which returns the parent's ID. + /// Gets attendees for a calendar item. /// public Task> GetAttendeesAsync(Guid calendarEventTrackingId) => Connection.Table().Where(x => x.CalendarItemId == calendarEventTrackingId).ToListAsync(); @@ -400,7 +252,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService { var eventId = targetDetails.Item.Id; - // Get the event by Id first. + // Get the event by Id first (this already loads AssignedCalendar). var item = await GetCalendarItemAsync(eventId).ConfigureAwait(false); bool isRecurringChild = targetDetails.Item.IsRecurringChild; @@ -412,9 +264,9 @@ public class CalendarService : BaseDatabaseService, ICalendarService { if (item == null) { - // This is an occurrence of a recurring event. - // They don't exist in db. - + // This occurrence doesn't exist in db - return the passed item. + // Ensure AssignedCalendar is loaded. + await LoadAssignedCalendarAsync(targetDetails.Item); return targetDetails.Item; } else @@ -458,8 +310,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService } /// - /// Gets reminders for a calendar item. For recurring event occurrences, - /// callers should pass the EventTrackingId which returns the parent's ID. + /// Gets reminders for a calendar item. /// public Task> GetRemindersAsync(Guid calendarItemId) => Connection.Table().Where(r => r.CalendarItemId == calendarItemId).ToListAsync(); @@ -492,6 +343,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService foreach (var item in attachments) { + // Check if an attachment with the same RemoteAttachmentId already exists for this calendar item + // to avoid re-downloading already existing attachments. + var existingAttachment = await Connection.Table() + .FirstOrDefaultAsync(x => x.CalendarItemId == item.CalendarItemId + && x.RemoteAttachmentId == item.RemoteAttachmentId); + + if (existingAttachment != null) + { + // Preserve the existing Id, IsDownloaded status, and LocalFilePath + item.Id = existingAttachment.Id; + item.IsDownloaded = existingAttachment.IsDownloaded; + item.LocalFilePath = existingAttachment.LocalFilePath; + } + await Connection.InsertOrReplaceAsync(item, typeof(CalendarAttachment)); } }