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));
}
}