Event creation.

This commit is contained in:
Burak Kaan Köse
2026-03-07 17:13:48 +01:00
parent d1f8163d72
commit ebc35c3de8
21 changed files with 921 additions and 161 deletions
@@ -33,6 +33,7 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
private readonly IContactService _contactService; private readonly IContactService _contactService;
private readonly IPreferencesService _preferencesService; private readonly IPreferencesService _preferencesService;
private readonly IUnderlyingThemeService _underlyingThemeService; private readonly IUnderlyingThemeService _underlyingThemeService;
private readonly IWinoRequestDelegator _winoRequestDelegator;
private readonly CalendarEventComposeResultValidator _composeResultValidator = new(); private readonly CalendarEventComposeResultValidator _composeResultValidator = new();
public Func<Task<string>> GetHtmlNotesAsync { get; set; } public Func<Task<string>> GetHtmlNotesAsync { get; set; }
@@ -110,7 +111,8 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
IMailDialogService dialogService, IMailDialogService dialogService,
IContactService contactService, IContactService contactService,
IPreferencesService preferencesService, IPreferencesService preferencesService,
IUnderlyingThemeService underlyingThemeService) IUnderlyingThemeService underlyingThemeService,
IWinoRequestDelegator winoRequestDelegator)
{ {
_accountService = accountService; _accountService = accountService;
_calendarService = calendarService; _calendarService = calendarService;
@@ -119,6 +121,7 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
_contactService = contactService; _contactService = contactService;
_preferencesService = preferencesService; _preferencesService = preferencesService;
_underlyingThemeService = underlyingThemeService; _underlyingThemeService = underlyingThemeService;
_winoRequestDelegator = winoRequestDelegator;
CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); CurrentSettings = _preferencesService.GetCurrentCalendarSettings();
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark(); IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
@@ -290,6 +293,10 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
LastCreatedResult = createdResult; LastCreatedResult = createdResult;
await _winoRequestDelegator.ExecuteAsync(new CalendarOperationPreparationRequest(
CalendarSynchronizerOperation.CreateEvent,
ComposeResult: createdResult));
_navigationService.GoBack(); _navigationService.GoBack();
} }
@@ -505,6 +512,12 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
private void UpdateRecurrenceSummary() private void UpdateRecurrenceSummary()
{ {
if (!HasInitializedComposeDateRange())
{
RecurrenceSummary = string.Empty;
return;
}
var effectiveStart = GetEffectiveStartDateTime(); var effectiveStart = GetEffectiveStartDateTime();
var effectiveEnd = GetEffectiveEndDateTime(); var effectiveEnd = GetEffectiveEndDateTime();
var selectedDays = WeekdayOptions var selectedDays = WeekdayOptions
@@ -524,6 +537,16 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel
RecurrenceEndDate); RecurrenceEndDate);
} }
private bool HasInitializedComposeDateRange()
{
if (StartDate == default)
{
return false;
}
return !IsAllDay || AllDayEndDate != default;
}
private string BuildRecurrenceRule() private string BuildRecurrenceRule()
{ {
if (!IsRecurring || SelectedRecurrenceFrequencyOption == null) if (!IsRecurring || SelectedRecurrenceFrequencyOption == null)
@@ -17,6 +17,7 @@ using Wino.Calendar.ViewModels.Messages;
using Wino.Core.Domain.Collections; using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies; using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies;
@@ -271,36 +272,31 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
{ {
var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime;
var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime;
var durationSeconds = (endDate - startDate).TotalSeconds; var composeResult = new CalendarEventComposeResult
// Get the user's current timezone from the system
var currentTimeZone = TimeZoneInfo.Local.Id;
var calendarItem = new CalendarItem
{ {
Id = Guid.NewGuid(),
CalendarId = SelectedQuickEventAccountCalendar.Id, CalendarId = SelectedQuickEventAccountCalendar.Id,
StartDate = startDate, AccountId = SelectedQuickEventAccountCalendar.Account.Id,
DurationInSeconds = durationSeconds,
StartTimeZone = currentTimeZone,
EndTimeZone = currentTimeZone,
CreatedAt = DateTime.UtcNow,
Description = string.Empty,
Location = Location ?? string.Empty,
Title = EventName, Title = EventName,
Location = Location ?? string.Empty,
HtmlNotes = string.Empty,
StartDate = startDate,
EndDate = endDate,
IsAllDay = IsAllDay,
TimeZoneId = TimeZoneInfo.Local.Id,
ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs, ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs,
IsHidden = false, SelectedReminders = [],
AssignedCalendar = SelectedQuickEventAccountCalendar Attendees = [],
Attachments = [],
Recurrence = string.Empty,
RecurrenceSummary = string.Empty
}; };
// Close dialog first // Close dialog first
IsQuickEventDialogOpen = false; IsQuickEventDialogOpen = false;
// Save to local database first var preparationRequest = new CalendarOperationPreparationRequest(
// await _calendarService.CreateNewCalendarItemAsync(calendarItem, null); CalendarSynchronizerOperation.CreateEvent,
ComposeResult: composeResult);
// Queue the request via delegator
var preparationRequest = new CalendarOperationPreparationRequest(CalendarSynchronizerOperation.CreateEvent, calendarItem, null);
await _winoRequestDelegator.ExecuteAsync(preparationRequest); await _winoRequestDelegator.ExecuteAsync(preparationRequest);
} }
catch (Exception ex) catch (Exception ex)
@@ -969,19 +965,9 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
private static bool TryExtractClientItemIdFromRemoteEventId(string remoteEventId, out Guid clientItemId) private static bool TryExtractClientItemIdFromRemoteEventId(string remoteEventId, out Guid clientItemId)
{ {
clientItemId = Guid.Empty; var trackingId = remoteEventId.GetClientTrackingId();
clientItemId = trackingId ?? Guid.Empty;
if (string.IsNullOrWhiteSpace(remoteEventId)) return trackingId.HasValue;
return false;
var uid = remoteEventId.Split(new[] { "::" }, StringSplitOptions.None)[0];
const string calDavPrefix = "caldav-";
if (!uid.StartsWith(calDavPrefix, StringComparison.OrdinalIgnoreCase))
return false;
var guidPart = uid[calDavPrefix.Length..];
return Guid.TryParseExact(guidPart, "N", out clientItemId) || Guid.TryParse(guidPart, out clientItemId);
} }
private void RemoveCalendarItemEverywhere(Guid calendarItemId) private void RemoveCalendarItemEverywhere(Guid calendarItemId)
@@ -0,0 +1,67 @@
using System;
namespace Wino.Core.Domain.Extensions;
public static class CalendarRemoteEventIdExtensions
{
private const string ClientTrackingSeparator = "::";
private const string CalDavClientTrackingPrefix = "caldav-";
private const string LocalClientTrackingPrefix = "local-";
public static string GetProviderRemoteEventId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return string.Empty;
var separatorIndex = remoteEventId.IndexOf(ClientTrackingSeparator, StringComparison.Ordinal);
return separatorIndex >= 0 ? remoteEventId[..separatorIndex] : remoteEventId;
}
public static Guid? GetClientTrackingId(this string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return null;
if (remoteEventId.Contains(ClientTrackingSeparator, StringComparison.Ordinal))
{
var trackedPart = remoteEventId[(remoteEventId.LastIndexOf(ClientTrackingSeparator, StringComparison.Ordinal) + ClientTrackingSeparator.Length)..];
if (TryParseGuid(trackedPart, out var trackedId))
return trackedId;
}
if (TryParseGuid(remoteEventId, out var directId))
return directId;
if (remoteEventId.StartsWith(CalDavClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[CalDavClientTrackingPrefix.Length..], out var calDavId))
{
return calDavId;
}
if (remoteEventId.StartsWith(LocalClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) &&
TryParseGuid(remoteEventId[LocalClientTrackingPrefix.Length..], out var localId))
{
return localId;
}
return null;
}
public static string WithClientTrackingId(this string providerRemoteEventId, Guid? clientTrackingId)
{
if (string.IsNullOrWhiteSpace(providerRemoteEventId) || !clientTrackingId.HasValue)
return providerRemoteEventId ?? string.Empty;
return $"{providerRemoteEventId}{ClientTrackingSeparator}{clientTrackingId.Value:N}";
}
private static bool TryParseGuid(string value, out Guid parsedGuid)
{
parsedGuid = Guid.Empty;
if (string.IsNullOrWhiteSpace(value))
return false;
return Guid.TryParseExact(value, "N", out parsedGuid) || Guid.TryParse(value, out parsedGuid);
}
}
@@ -1,4 +1,5 @@
using Wino.Core.Domain.Entities.Calendar; using System;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
@@ -68,5 +69,6 @@ public interface IFolderActionRequest : IRequestBase
public interface ICalendarActionRequest : IRequestBase public interface ICalendarActionRequest : IRequestBase
{ {
CalendarItem Item { get; } CalendarItem Item { get; }
Guid? LocalCalendarItemId { get; }
CalendarSynchronizerOperation Operation { get; } CalendarSynchronizerOperation Operation { get; }
} }
@@ -15,8 +15,9 @@ namespace Wino.Core.Domain.Models.Calendar;
/// <param name="OriginalAttendees">Original attendees list before update (for revert capability).</param> /// <param name="OriginalAttendees">Original attendees list before update (for revert capability).</param>
public record CalendarOperationPreparationRequest( public record CalendarOperationPreparationRequest(
CalendarSynchronizerOperation Operation, CalendarSynchronizerOperation Operation,
CalendarItem CalendarItem, CalendarItem CalendarItem = null,
List<CalendarEventAttendee> Attendees, List<CalendarEventAttendee> Attendees = null,
string ResponseMessage = null, string ResponseMessage = null,
CalendarItem OriginalItem = null, CalendarItem OriginalItem = null,
List<CalendarEventAttendee> OriginalAttendees = null); List<CalendarEventAttendee> OriginalAttendees = null,
CalendarEventComposeResult ComposeResult = null);
@@ -32,6 +32,7 @@ public abstract record FolderRequestBase(MailItemFolder Folder, FolderSynchroniz
public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase<CalendarSynchronizerOperation>, ICalendarActionRequest public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase<CalendarSynchronizerOperation>, ICalendarActionRequest
{ {
public virtual Guid? LocalCalendarItemId => Item?.Id;
} }
public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest public class BatchCollection<TRequestType> : List<TRequestType>, IUIChangeRequest where TRequestType : IUIChangeRequest
@@ -89,6 +89,7 @@
"SyncAction_Archiving": "Archiving {0} mail(s)", "SyncAction_Archiving": "Archiving {0} mail(s)",
"SyncAction_ClearingFlag": "Unflagging {0} mail(s)", "SyncAction_ClearingFlag": "Unflagging {0} mail(s)",
"SyncAction_CreatingDraft": "Creating draft", "SyncAction_CreatingDraft": "Creating draft",
"SyncAction_CreatingEvent": "Creating event",
"SyncAction_Deleting": "Deleting {0} mail(s)", "SyncAction_Deleting": "Deleting {0} mail(s)",
"SyncAction_EmptyingFolder": "Emptying folder", "SyncAction_EmptyingFolder": "Emptying folder",
"SyncAction_MarkingAsRead": "Marking {0} mail(s) as read", "SyncAction_MarkingAsRead": "Marking {0} mail(s) as read",
@@ -148,6 +149,7 @@
"CalendarEventCompose_PickCalendarTitle": "Pick a calendar", "CalendarEventCompose_PickCalendarTitle": "Pick a calendar",
"CalendarEventCompose_Recurring": "Recurring", "CalendarEventCompose_Recurring": "Recurring",
"CalendarEventCompose_RecurringSummary": "Occurs every {0} {1}{2} {3} effective {4}{5}", "CalendarEventCompose_RecurringSummary": "Occurs every {0} {1}{2} {3} effective {4}{5}",
"CalendarEventCompose_RecurringSummarySmart": "Occurs {0}{1} {2} starting {3}{4}",
"CalendarEventCompose_RepeatEvery": "Repeat every", "CalendarEventCompose_RepeatEvery": "Repeat every",
"CalendarEventCompose_SelectCalendar": "Select calendar", "CalendarEventCompose_SelectCalendar": "Select calendar",
"CalendarEventCompose_SingleOccurrenceSummary": "Occurs on {0} {1}", "CalendarEventCompose_SingleOccurrenceSummary": "Occurs on {0} {1}",
@@ -99,6 +99,7 @@ public class AccountServiceTests : IAsyncLifetime
var authenticationProvider = new Mock<IAuthenticationProvider>(); var authenticationProvider = new Mock<IAuthenticationProvider>();
var mimeFileService = new Mock<IMimeFileService>(); var mimeFileService = new Mock<IMimeFileService>();
var contactPictureFileService = new Mock<IContactPictureFileService>();
var preferencesService = new Mock<IPreferencesService>(); var preferencesService = new Mock<IPreferencesService>();
preferencesService.SetupProperty(a => a.StartupEntityId); preferencesService.SetupProperty(a => a.StartupEntityId);
@@ -108,6 +109,7 @@ public class AccountServiceTests : IAsyncLifetime
signatureService.Object, signatureService.Object,
authenticationProvider.Object, authenticationProvider.Object,
mimeFileService.Object, mimeFileService.Object,
preferencesService.Object); preferencesService.Object,
contactPictureFileService.Object);
} }
} }
@@ -0,0 +1,127 @@
using CommunityToolkit.Mvvm.Messaging;
using FluentAssertions;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Helpers;
using Wino.Core.Requests.Calendar;
using Wino.Messaging.Client.Calendar;
using Xunit;
namespace Wino.Core.Tests.Services;
public sealed class CreateCalendarEventRequestTests
{
[Fact]
public void ApplyUiChanges_ForNonRecurringEvent_SendsOptimisticAddAndRevertDelete()
{
var composeResult = CreateComposeResult();
var assignedCalendar = CreateAssignedCalendar();
var request = new CreateCalendarEventRequest(composeResult, assignedCalendar);
var recipient = new CalendarRequestRecipient();
WeakReferenceMessenger.Default.RegisterAll(recipient);
try
{
request.LocalCalendarItemId.Should().NotBeNull();
request.ApplyUIChanges();
request.RevertUIChanges();
recipient.Added.Should().ContainSingle();
recipient.Deleted.Should().ContainSingle();
recipient.Added[0].Id.Should().Be(request.LocalCalendarItemId!.Value);
recipient.Deleted[0].Id.Should().Be(request.LocalCalendarItemId!.Value);
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}
[Fact]
public void ApplyUiChanges_ForRecurringEvent_DoesNotSendOptimisticMessages()
{
var composeResult = CreateComposeResult();
composeResult.Recurrence = "RRULE:FREQ=DAILY;INTERVAL=1";
var request = new CreateCalendarEventRequest(composeResult, CreateAssignedCalendar());
var recipient = new CalendarRequestRecipient();
WeakReferenceMessenger.Default.RegisterAll(recipient);
try
{
request.LocalCalendarItemId.Should().BeNull();
request.Item.Should().BeNull();
request.ApplyUIChanges();
request.RevertUIChanges();
recipient.Added.Should().BeEmpty();
recipient.Deleted.Should().BeEmpty();
request.PreparedItem.Should().NotBeNull();
}
finally
{
WeakReferenceMessenger.Default.UnregisterAll(recipient);
}
}
[Fact]
public void SynchronizationActionHelper_ForCreateRequest_ReturnsCalendarCreateAction()
{
var request = new CreateCalendarEventRequest(CreateComposeResult(), CreateAssignedCalendar());
var actionItems = SynchronizationActionHelper.CreateActionItems([request], Guid.NewGuid(), "Test");
actionItems.Should().ContainSingle();
actionItems[0].Description.Should().Be(Wino.Core.Domain.Translator.SyncAction_CreatingEvent);
}
private static CalendarEventComposeResult CreateComposeResult()
{
return new CalendarEventComposeResult
{
CalendarId = Guid.NewGuid(),
AccountId = Guid.NewGuid(),
Title = "Planning",
Location = "Room 4",
HtmlNotes = "<p>Notes</p>",
StartDate = new DateTime(2026, 3, 7, 10, 0, 0),
EndDate = new DateTime(2026, 3, 7, 11, 0, 0),
TimeZoneId = TimeZoneInfo.Local.Id,
ShowAs = CalendarItemShowAs.Busy
};
}
private static AccountCalendar CreateAssignedCalendar()
{
return new AccountCalendar
{
Id = Guid.NewGuid(),
AccountId = Guid.NewGuid(),
Name = "Primary",
DefaultShowAs = CalendarItemShowAs.Busy,
MailAccount = new MailAccount
{
Id = Guid.NewGuid(),
Address = "user@example.com",
SenderName = "User"
}
};
}
internal sealed class CalendarRequestRecipient :
IRecipient<CalendarItemAdded>,
IRecipient<CalendarItemDeleted>
{
public List<CalendarItem> Added { get; } = [];
public List<CalendarItem> Deleted { get; } = [];
public void Receive(CalendarItemAdded message) => Added.Add(message.CalendarItem);
public void Receive(CalendarItemDeleted message) => Deleted.Add(message.CalendarItem);
}
}
@@ -358,13 +358,15 @@ public class MailFetchingTests : IAsyncLifetime
var authProvider = new Mock<IAuthenticationProvider>(); var authProvider = new Mock<IAuthenticationProvider>();
var mimeFileService = new Mock<IMimeFileService>(); var mimeFileService = new Mock<IMimeFileService>();
var preferencesService = new Mock<IPreferencesService>(); var preferencesService = new Mock<IPreferencesService>();
var contactPictureFileService = new Mock<IContactPictureFileService>();
var accountService = new AccountService( var accountService = new AccountService(
db, db,
signatureService.Object, signatureService.Object,
authProvider.Object, authProvider.Object,
mimeFileService.Object, mimeFileService.Object,
preferencesService.Object); preferencesService.Object,
contactPictureFileService.Object);
var folderService = new FolderService(db, accountService); var folderService = new FolderService(db, accountService);
var contactService = new ContactService(db); var contactService = new ContactService(db);
@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Core.Helpers;
public sealed record PreparedCalendarEventCreateModel(
CalendarItem CalendarItem,
List<CalendarEventAttendee> Attendees,
List<Reminder> Reminders);
public static class CalendarEventComposeMapper
{
public static PreparedCalendarEventCreateModel Prepare(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar, Guid? calendarItemId = null)
{
ArgumentNullException.ThrowIfNull(composeResult);
ArgumentNullException.ThrowIfNull(assignedCalendar);
var itemId = calendarItemId ?? Guid.NewGuid();
var effectiveTimeZoneId = string.IsNullOrWhiteSpace(composeResult.TimeZoneId)
? TimeZoneInfo.Local.Id
: composeResult.TimeZoneId;
var utcNow = DateTimeOffset.UtcNow;
var calendarItem = new CalendarItem
{
Id = itemId,
CalendarId = assignedCalendar.Id,
AssignedCalendar = assignedCalendar,
Title = composeResult.Title?.Trim() ?? string.Empty,
Description = composeResult.HtmlNotes ?? string.Empty,
Location = composeResult.Location?.Trim() ?? string.Empty,
StartDate = composeResult.StartDate,
DurationInSeconds = Math.Max(0, (composeResult.EndDate - composeResult.StartDate).TotalSeconds),
StartTimeZone = effectiveTimeZoneId,
EndTimeZone = effectiveTimeZoneId,
CreatedAt = utcNow,
UpdatedAt = utcNow,
Recurrence = composeResult.Recurrence ?? string.Empty,
OrganizerDisplayName = assignedCalendar.MailAccount?.SenderName ?? string.Empty,
OrganizerEmail = assignedCalendar.MailAccount?.Address ?? string.Empty,
Status = CalendarItemStatus.Accepted,
Visibility = CalendarItemVisibility.Public,
ShowAs = composeResult.ShowAs,
IsHidden = false,
IsLocked = false
};
var attendees = composeResult.Attendees?
.Where(attendee => attendee != null)
.Select(attendee => new CalendarEventAttendee
{
Id = attendee.Id == Guid.Empty ? Guid.NewGuid() : attendee.Id,
CalendarItemId = itemId,
Name = attendee.Name ?? string.Empty,
Email = attendee.Email ?? string.Empty,
Comment = attendee.Comment,
AttendenceStatus = attendee.AttendenceStatus,
IsOrganizer = attendee.IsOrganizer,
IsOptionalAttendee = attendee.IsOptionalAttendee,
ResolvedContact = attendee.ResolvedContact
})
.ToList() ?? [];
var reminders = composeResult.SelectedReminders?
.Where(reminder => reminder != null)
.Select(reminder => new Reminder
{
Id = reminder.Id == Guid.Empty ? Guid.NewGuid() : reminder.Id,
CalendarItemId = itemId,
DurationInSeconds = reminder.DurationInSeconds,
ReminderType = reminder.ReminderType
})
.ToList() ?? [];
return new PreparedCalendarEventCreateModel(calendarItem, attendees, reminders);
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
namespace Wino.Core.Helpers;
public static class CalendarRecurrenceMapper
{
public static PatternedRecurrence CreateOutlookRecurrence(CalendarItem calendarItem)
{
if (calendarItem == null || string.IsNullOrWhiteSpace(calendarItem.Recurrence))
return null;
var ruleLine = calendarItem.Recurrence
.Split(Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => line.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(ruleLine))
return null;
var components = ruleLine["RRULE:".Length..]
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
.Where(parts => parts.Length == 2)
.ToDictionary(parts => parts[0].ToUpperInvariant(), parts => parts[1], StringComparer.OrdinalIgnoreCase);
if (!components.TryGetValue("FREQ", out var frequency))
return null;
var pattern = new RecurrencePattern
{
Interval = ParseInt(components, "INTERVAL", 1),
FirstDayOfWeek = DayOfWeekObject.Monday
};
var byDays = ParseByDays(components);
var startDate = calendarItem.StartDate;
switch (frequency.ToUpperInvariant())
{
case "DAILY":
pattern.Type = RecurrencePatternType.Daily;
break;
case "WEEKLY":
pattern.Type = RecurrencePatternType.Weekly;
pattern.DaysOfWeek = byDays.Any()
? byDays.Select(day => (DayOfWeekObject?)day).ToList()
: [(DayOfWeekObject?)MapDay(startDate.DayOfWeek)];
break;
case "MONTHLY":
if (byDays.Any())
{
pattern.Type = RecurrencePatternType.RelativeMonthly;
pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList();
pattern.Index = MapWeekIndex(startDate);
}
else
{
pattern.Type = RecurrencePatternType.AbsoluteMonthly;
pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day);
}
break;
case "YEARLY":
pattern.Month = ParseInt(components, "BYMONTH", startDate.Month);
if (byDays.Any())
{
pattern.Type = RecurrencePatternType.RelativeYearly;
pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList();
pattern.Index = MapWeekIndex(startDate);
}
else
{
pattern.Type = RecurrencePatternType.AbsoluteYearly;
pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day);
}
break;
default:
return null;
}
var recurrenceRange = CreateRange(components, calendarItem);
return new PatternedRecurrence
{
Pattern = pattern,
Range = recurrenceRange
};
}
private static RecurrenceRange CreateRange(IReadOnlyDictionary<string, string> components, CalendarItem calendarItem)
{
var startDate = CreateDate(calendarItem.StartDate);
if (components.TryGetValue("UNTIL", out var untilValue) &&
TryParseUntil(untilValue, out var untilDate))
{
return new RecurrenceRange
{
Type = RecurrenceRangeType.EndDate,
StartDate = startDate,
EndDate = CreateDate(untilDate),
RecurrenceTimeZone = calendarItem.StartTimeZone
};
}
return new RecurrenceRange
{
Type = RecurrenceRangeType.NoEnd,
StartDate = startDate,
RecurrenceTimeZone = calendarItem.StartTimeZone
};
}
private static bool TryParseUntil(string untilValue, out DateTime untilDate)
{
untilDate = default;
if (string.IsNullOrWhiteSpace(untilValue))
return false;
return DateTime.TryParseExact(
untilValue,
["yyyyMMdd", "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'"],
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out untilDate)
|| DateTime.TryParse(untilValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out untilDate);
}
private static List<DayOfWeekObject> ParseByDays(IReadOnlyDictionary<string, string> components)
{
if (!components.TryGetValue("BYDAY", out var byDayValue) || string.IsNullOrWhiteSpace(byDayValue))
return [];
return byDayValue
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(MapDay)
.ToList();
}
private static int ParseInt(IReadOnlyDictionary<string, string> components, string key, int fallback)
=> components.TryGetValue(key, out var value) && int.TryParse(value, out var parsedValue) ? parsedValue : fallback;
private static DayOfWeekObject MapDay(string dayToken)
{
return dayToken.ToUpperInvariant() switch
{
"MO" => DayOfWeekObject.Monday,
"TU" => DayOfWeekObject.Tuesday,
"WE" => DayOfWeekObject.Wednesday,
"TH" => DayOfWeekObject.Thursday,
"FR" => DayOfWeekObject.Friday,
"SA" => DayOfWeekObject.Saturday,
"SU" => DayOfWeekObject.Sunday,
_ => throw new ArgumentOutOfRangeException(nameof(dayToken), dayToken, null)
};
}
private static DayOfWeekObject MapDay(DayOfWeek dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeek.Monday => DayOfWeekObject.Monday,
DayOfWeek.Tuesday => DayOfWeekObject.Tuesday,
DayOfWeek.Wednesday => DayOfWeekObject.Wednesday,
DayOfWeek.Thursday => DayOfWeekObject.Thursday,
DayOfWeek.Friday => DayOfWeekObject.Friday,
DayOfWeek.Saturday => DayOfWeekObject.Saturday,
DayOfWeek.Sunday => DayOfWeekObject.Sunday,
_ => DayOfWeekObject.Monday
};
}
private static WeekIndex MapWeekIndex(DateTime date)
{
var occurrence = ((date.Day - 1) / 7) + 1;
return occurrence switch
{
1 => WeekIndex.First,
2 => WeekIndex.Second,
3 => WeekIndex.Third,
4 => WeekIndex.Fourth,
_ => WeekIndex.Last
};
}
private static Date CreateDate(DateTime dateTime) => new(dateTime.Year, dateTime.Month, dateTime.Day);
}
@@ -5,6 +5,7 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Folder; using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail; using Wino.Core.Requests.Mail;
@@ -56,6 +57,22 @@ public static class SynchronizationActionHelper
} }
} }
var calendarRequests = requests.OfType<ICalendarActionRequest>();
foreach (var calendarRequest in calendarRequests)
{
var description = GetCalendarActionDescription(calendarRequest);
if (description != null)
{
items.Add(new SynchronizationActionItem
{
AccountId = accountId,
AccountName = accountName,
Description = description
});
}
}
return items; return items;
} }
@@ -107,4 +124,13 @@ public static class SynchronizationActionHelper
_ => null _ => null
}; };
} }
private static string GetCalendarActionDescription(ICalendarActionRequest request)
{
return request switch
{
CreateCalendarEventRequest => Translator.SyncAction_CreatingEvent,
_ => null
};
}
} }
@@ -8,6 +8,7 @@ using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Extensions;
using Wino.Core.Extensions; using Wino.Core.Extensions;
using Wino.Services; using Wino.Services;
using Reminder = Wino.Core.Domain.Entities.Calendar.Reminder; using Reminder = Wino.Core.Domain.Entities.Calendar.Reminder;
@@ -64,7 +65,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds; var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
// Store dates as UTC in the database // Store dates as UTC in the database
savingItem.RemoteEventId = calendarEvent.Id; savingItem.RemoteEventId = calendarEvent.Id.WithClientTrackingId(calendarEvent.TransactionId.GetClientTrackingId());
savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime; savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime;
savingItem.DurationInSeconds = durationInSeconds; savingItem.DurationInSeconds = durationInSeconds;
@@ -1,35 +1,63 @@
using System; using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Requests;
using Wino.Core.Helpers;
using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Calendar;
namespace Wino.Core.Requests.Calendar; namespace Wino.Core.Requests.Calendar;
/// <summary> /// <summary>
/// Request to create a new calendar event on the server. /// Request to create a new calendar event on the server.
/// The calendar item should be already saved to the local database before queuing this request. /// Non-recurring events create an optimistic in-memory item for immediate UI feedback.
/// Recurring events skip optimistic rendering and rely on provider synchronization to materialize instances.
/// </summary> /// </summary>
public record CreateCalendarEventRequest(CalendarItem Item, List<CalendarEventAttendee> Attendees) : CalendarRequestBase(Item) public record CreateCalendarEventRequest : CalendarRequestBase
{ {
public CalendarEventComposeResult ComposeResult { get; }
public AccountCalendar AssignedCalendar { get; }
public PreparedCalendarEventCreateModel PreparedEvent { get; }
public CalendarItem PreparedItem => PreparedEvent.CalendarItem;
public bool IsRecurring => !string.IsNullOrWhiteSpace(ComposeResult?.Recurrence);
public CreateCalendarEventRequest(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar)
: this(composeResult, assignedCalendar, CalendarEventComposeMapper.Prepare(composeResult, assignedCalendar))
{
}
private CreateCalendarEventRequest(
CalendarEventComposeResult composeResult,
AccountCalendar assignedCalendar,
PreparedCalendarEventCreateModel preparedEvent)
: base(ShouldCreateOptimisticItem(composeResult) ? preparedEvent.CalendarItem : null)
{
ComposeResult = composeResult ?? throw new ArgumentNullException(nameof(composeResult));
AssignedCalendar = assignedCalendar ?? throw new ArgumentNullException(nameof(assignedCalendar));
PreparedEvent = preparedEvent ?? throw new ArgumentNullException(nameof(preparedEvent));
}
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.CreateEvent; public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.CreateEvent;
/// <summary> public override int ResynchronizationDelay => 5000;
/// After successful creation, we need to resync to get the remote event ID.
/// </summary>
public override int ResynchronizationDelay => 2000;
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
// Notify UI that the event was created locally if (Item == null)
return;
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
{ {
// If creation fails, we should notify the UI to remove it if (Item == null)
return;
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item));
} }
private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult)
=> string.IsNullOrWhiteSpace(composeResult?.Recurrence);
} }
+33 -4
View File
@@ -27,16 +27,19 @@ public class WinoRequestDelegator : IWinoRequestDelegator
private readonly IFolderService _folderService; private readonly IFolderService _folderService;
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ICalendarService _calendarService;
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor, public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
IFolderService folderService, IFolderService folderService,
IMailDialogService dialogService, IMailDialogService dialogService,
IAccountService accountService) IAccountService accountService,
ICalendarService calendarService)
{ {
_winoRequestProcessor = winoRequestProcessor; _winoRequestProcessor = winoRequestProcessor;
_folderService = folderService; _folderService = folderService;
_dialogService = dialogService; _dialogService = dialogService;
_accountService = accountService; _accountService = accountService;
_calendarService = calendarService;
} }
public async Task ExecuteAsync(MailOperationPreperationRequest request) public async Task ExecuteAsync(MailOperationPreperationRequest request)
@@ -159,9 +162,12 @@ public class WinoRequestDelegator : IWinoRequestDelegator
public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest) public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
{ {
if (calendarPreparationRequest == null)
return;
IRequestBase request = calendarPreparationRequest.Operation switch IRequestBase request = calendarPreparationRequest.Operation switch
{ {
CalendarSynchronizerOperation.CreateEvent => new CreateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees), CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem), CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem),
CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
@@ -174,8 +180,31 @@ public class WinoRequestDelegator : IWinoRequestDelegator
_ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.") _ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.")
}; };
await QueueRequestAsync(request, calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId); if (request == null)
await QueueCalendarSynchronizationAsync(calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId); return;
var accountId = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent
? calendarPreparationRequest.ComposeResult.AccountId
: calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId;
var accountName = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent
? null
: calendarPreparationRequest.CalendarItem.AssignedCalendar.MailAccount?.Name;
await QueueRequestAsync(request, accountId);
await SendSyncActionsAddedAsync([request], accountId, accountName);
await QueueCalendarSynchronizationAsync(accountId);
}
private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
{
var composeResult = calendarPreparationRequest.ComposeResult
?? throw new InvalidOperationException("Create event requests require a compose result.");
var assignedCalendar = await _calendarService.GetAccountCalendarAsync(composeResult.CalendarId).ConfigureAwait(false);
if (assignedCalendar == null)
throw new InvalidOperationException($"Calendar {composeResult.CalendarId} could not be resolved.");
return new CreateCalendarEventRequest(composeResult, assignedCalendar);
} }
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage) private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
+8 -2
View File
@@ -144,7 +144,10 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
if (request is ICalendarActionRequest calendarActionRequest) if (request is ICalendarActionRequest calendarActionRequest)
{ {
_pendingCalendarOperationIds.TryAdd(calendarActionRequest.Item.Id, 0); if (calendarActionRequest.LocalCalendarItemId.HasValue)
{
_pendingCalendarOperationIds.TryAdd(calendarActionRequest.LocalCalendarItemId.Value, 0);
}
} }
} }
@@ -157,7 +160,10 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
if (request is ICalendarActionRequest calendarActionRequest) if (request is ICalendarActionRequest calendarActionRequest)
{ {
_pendingCalendarOperationIds.TryRemove(calendarActionRequest.Item.Id, out _); if (calendarActionRequest.LocalCalendarItemId.HasValue)
{
_pendingCalendarOperationIds.TryRemove(calendarActionRequest.LocalCalendarItemId.Value, out _);
}
} }
} }
+78 -41
View File
@@ -2276,55 +2276,49 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
public override List<IRequestBundle<IClientServiceRequest>> CreateCalendarEvent(CreateCalendarEventRequest request) public override List<IRequestBundle<IClientServiceRequest>> CreateCalendarEvent(CreateCalendarEventRequest request)
{ {
var calendarItem = request.Item; var calendarItem = request.PreparedItem;
var attendees = request.Attendees; var attendees = request.PreparedEvent.Attendees;
var reminders = request.PreparedEvent.Reminders;
var calendar = request.AssignedCalendar;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
// Convert CalendarItem to Google Event
var googleEvent = new Event var googleEvent = new Event
{ {
Id = calendarItem.Id.ToString("N").ToLowerInvariant(),
Summary = calendarItem.Title, Summary = calendarItem.Title,
Description = calendarItem.Description, Description = calendarItem.Description,
Location = calendarItem.Location, Location = calendarItem.Location,
Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative" Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative",
Transparency = calendarItem.ShowAs == CalendarItemShowAs.Free ? "transparent" : "opaque"
}; };
// Set start and end time
if (calendarItem.IsAllDayEvent) if (calendarItem.IsAllDayEvent)
{ {
// All-day events use Date instead of DateTime
googleEvent.Start = new EventDateTime googleEvent.Start = new EventDateTime
{ {
Date = calendarItem.StartDate.ToString("yyyy-MM-dd") Date = calendarItem.StartDate.ToString("yyyy-MM-dd"),
};
googleEvent.End = new EventDateTime
{
Date = calendarItem.EndDate.ToString("yyyy-MM-dd")
};
}
else
{
// Regular events with time
googleEvent.Start = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero),
TimeZone = calendarItem.StartTimeZone TimeZone = calendarItem.StartTimeZone
}; };
googleEvent.End = new EventDateTime googleEvent.End = new EventDateTime
{ {
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero), Date = calendarItem.EndDate.ToString("yyyy-MM-dd"),
TimeZone = calendarItem.EndTimeZone
};
}
else
{
googleEvent.Start = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, ResolveOffset(calendarItem.StartDate, calendarItem.StartTimeZone)),
TimeZone = calendarItem.StartTimeZone
};
googleEvent.End = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, ResolveOffset(calendarItem.EndDate, calendarItem.EndTimeZone ?? calendarItem.StartTimeZone)),
TimeZone = calendarItem.EndTimeZone TimeZone = calendarItem.EndTimeZone
}; };
} }
// Add attendees if any if (attendees.Count > 0)
if (attendees != null && attendees.Count > 0)
{ {
googleEvent.Attendees = attendees.Select(a => new EventAttendee googleEvent.Attendees = attendees.Select(a => new EventAttendee
{ {
@@ -2334,8 +2328,32 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}).ToList(); }).ToList();
} }
// Create the insert request if (reminders.Count > 0)
{
googleEvent.Reminders = new Event.RemindersData
{
UseDefault = false,
Overrides = reminders.Select(reminder => new EventReminder
{
Method = reminder.ReminderType == CalendarItemReminderType.Email ? "email" : "popup",
Minutes = (int)Math.Max(0, reminder.DurationInSeconds / 60)
}).ToList()
};
}
if (!string.IsNullOrWhiteSpace(calendarItem.Recurrence))
{
googleEvent.Recurrence = calendarItem.Recurrence
.Split(Wino.Core.Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
}
var insertRequest = _calendarService.Events.Insert(googleEvent, calendar.RemoteCalendarId); var insertRequest = _calendarService.Events.Insert(googleEvent, calendar.RemoteCalendarId);
insertRequest.SendUpdates = attendees.Count > 0
? Google.Apis.Calendar.v3.EventsResource.InsertRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.InsertRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(insertRequest, request)]; return [new HttpRequestBundle<IClientServiceRequest>(insertRequest, request)];
} }
@@ -2350,7 +2368,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot accept event without remote event ID"); throw new InvalidOperationException("Cannot accept event without remote event ID");
} }
@@ -2375,7 +2394,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
ResponseStatus = "accepted" ResponseStatus = "accepted"
} }
} }
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId); }, calendar.RemoteCalendarId, remoteEventId);
// Send updates to other attendees if there's a message // Send updates to other attendees if there's a message
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
@@ -2395,7 +2414,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot decline event without remote event ID"); throw new InvalidOperationException("Cannot decline event without remote event ID");
} }
@@ -2413,7 +2433,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
Comment = request.ResponseMessage Comment = request.ResponseMessage
} }
} }
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId); }, calendar.RemoteCalendarId, remoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All ? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
@@ -2432,7 +2452,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot tentatively accept event without remote event ID"); throw new InvalidOperationException("Cannot tentatively accept event without remote event ID");
} }
@@ -2450,7 +2471,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
Comment = request.ResponseMessage Comment = request.ResponseMessage
} }
} }
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId); }, calendar.RemoteCalendarId, remoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage) patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All ? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
@@ -2471,7 +2492,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot update event without remote event ID"); throw new InvalidOperationException("Cannot update event without remote event ID");
} }
@@ -2530,7 +2552,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
} }
// Update the event using Google Calendar API // Update the event using Google Calendar API
var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId); var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, remoteEventId);
// Send notifications to attendees if the event has attendees // Send notifications to attendees if the event has attendees
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0) updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
@@ -2551,13 +2573,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot delete event without remote event ID"); throw new InvalidOperationException("Cannot delete event without remote event ID");
} }
// Delete the event using Google Calendar API var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, remoteEventId);
var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send cancellation notifications to attendees // Send cancellation notifications to attendees
deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All; deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All;
@@ -2576,4 +2598,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_calendarService.Dispose(); _calendarService.Dispose();
_googleHttpClient.Dispose(); _googleHttpClient.Dispose();
} }
private static TimeSpan ResolveOffset(DateTime dateTime, string timeZoneId)
{
if (string.IsNullOrWhiteSpace(timeZoneId))
return TimeSpan.Zero;
try
{
return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId).GetUtcOffset(dateTime);
}
catch
{
return TimeSpan.Zero;
}
}
} }
+55 -9
View File
@@ -16,6 +16,7 @@ using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Connectivity;
@@ -95,7 +96,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream); var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
_clientPool = new ImapClientPool(poolOptions); _clientPool = new ImapClientPool(poolOptions);
_localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, "local"); _localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, _applicationConfiguration.ApplicationDataFolderPath, "local");
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(this, Account, _calendarService, _calDavClient); _calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(this, Account, _calendarService, _calDavClient);
} }
@@ -1531,32 +1532,38 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
private readonly MailAccount _account; private readonly MailAccount _account;
private readonly IImapChangeProcessor _changeProcessor; private readonly IImapChangeProcessor _changeProcessor;
private readonly ICalendarService _calendarService; private readonly ICalendarService _calendarService;
private readonly string _applicationDataFolderPath;
private readonly string _resourceScheme; private readonly string _resourceScheme;
public bool RequiresConnectedClient => false; public bool RequiresConnectedClient => false;
public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string resourceScheme) public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string applicationDataFolderPath, string resourceScheme)
{ {
_account = account; _account = account;
_changeProcessor = changeProcessor; _changeProcessor = changeProcessor;
_calendarService = calendarService; _calendarService = calendarService;
_applicationDataFolderPath = applicationDataFolderPath;
_resourceScheme = resourceScheme; _resourceScheme = resourceScheme;
} }
public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request) public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
{ {
var item = request.Item; var item = request.PreparedItem;
var attendees = request.PreparedEvent.Attendees;
var reminders = request.PreparedEvent.Reminders;
EnsureCalendarItemDefaults(item, _account, "local"); EnsureCalendarItemDefaults(item, _account, "local");
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false); item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false); var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false);
if (existing == null) if (existing == null)
await _calendarService.CreateNewCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); await _calendarService.CreateNewCalendarItemAsync(item, attendees).ConfigureAwait(false);
else else
await _calendarService.UpdateCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
await PersistIcsAsync(item, request.Attendees).ConfigureAwait(false); await _calendarService.SaveRemindersAsync(item.Id, reminders).ConfigureAwait(false);
await SaveAttachmentsAsync(request.ComposeResult, item.Id).ConfigureAwait(false);
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
} }
public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request) public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
@@ -1616,6 +1623,45 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
DateTimeOffset.UtcNow.ToString("O"), DateTimeOffset.UtcNow.ToString("O"),
icsContent); icsContent);
} }
private async Task SaveAttachmentsAsync(CalendarEventComposeResult composeResult, Guid calendarItemId)
{
await _calendarService.DeleteAttachmentsAsync(calendarItemId).ConfigureAwait(false);
var attachments = composeResult?.Attachments;
if (attachments == null || attachments.Count == 0)
return;
var attachmentsRoot = Path.Combine(_applicationDataFolderPath, "CalendarAttachments", calendarItemId.ToString("N"));
Directory.CreateDirectory(attachmentsRoot);
var storedAttachments = new List<CalendarAttachment>();
foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath)))
{
var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? Path.GetFileName(attachment.FilePath) : attachment.FileName;
var destinationPath = Path.Combine(attachmentsRoot, fileName);
File.Copy(attachment.FilePath, destinationPath, overwrite: true);
storedAttachments.Add(new CalendarAttachment
{
Id = Guid.NewGuid(),
CalendarItemId = calendarItemId,
RemoteAttachmentId = attachment.Id.ToString("N"),
FileName = fileName,
Size = attachment.Size,
ContentType = MimeTypes.GetMimeType(fileName),
IsDownloaded = true,
LocalFilePath = destinationPath,
LastModified = DateTimeOffset.UtcNow
});
}
if (storedAttachments.Count > 0)
{
await _calendarService.InsertOrReplaceAttachmentsAsync(storedAttachments).ConfigureAwait(false);
}
}
} }
private sealed class CalDavCalendarOperationHandler : IImapCalendarOperationHandler private sealed class CalDavCalendarOperationHandler : IImapCalendarOperationHandler
@@ -1640,7 +1686,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
} }
public Task CreateCalendarEventAsync(CreateCalendarEventRequest request) public Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
=> UpsertCalendarEventAsync(request.Item, request.Attendees); => UpsertCalendarEventAsync(request.PreparedItem, request.PreparedEvent.Attendees);
public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request) public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
=> UpsertCalendarEventAsync(request.Item, request.Attendees); => UpsertCalendarEventAsync(request.Item, request.Attendees);
@@ -1654,7 +1700,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
} }
await _calDavClient await _calDavClient
.DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId) .DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId.GetProviderRemoteEventId())
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -1689,7 +1735,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
var icsContent = BuildIcsContent(item, attendees); var icsContent = BuildIcsContent(item, attendees);
await _calDavClient await _calDavClient
.UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId, icsContent) .UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId.GetProviderRemoteEventId(), icsContent)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
+136 -48
View File
@@ -27,6 +27,7 @@ using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
@@ -34,6 +35,7 @@ using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions; using Wino.Core.Extensions;
using Wino.Core.Http; using Wino.Core.Http;
using Wino.Core.Helpers;
using Wino.Core.Integration.Processors; using Wino.Core.Integration.Processors;
using Wino.Core.Misc; using Wino.Core.Misc;
using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Bundles;
@@ -1634,11 +1636,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
try try
{ {
var calendar = calendarItem.AssignedCalendar; var calendar = calendarItem.AssignedCalendar;
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
// First, get the attachment metadata to retrieve contentBytes for FileAttachment // First, get the attachment metadata to retrieve contentBytes for FileAttachment
var attachmentItem = await _graphClient.Me var attachmentItem = await _graphClient.Me
.Calendars[calendar.RemoteCalendarId] .Calendars[calendar.RemoteCalendarId]
.Events[calendarItem.RemoteEventId] .Events[remoteEventId]
.Attachments[attachment.RemoteAttachmentId] .Attachments[attachment.RemoteAttachmentId]
.GetAsync(cancellationToken: cancellationToken) .GetAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -1879,9 +1882,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
private async Task HandleSuccessfulResponseAsync(IRequestBundle<RequestInformation> bundle, HttpResponseMessage response) private async Task HandleSuccessfulResponseAsync(IRequestBundle<RequestInformation> bundle, HttpResponseMessage response)
{ {
if (bundle?.UIChangeRequest is not CreateDraftRequest createDraftRequest)
return;
try try
{ {
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -1889,24 +1889,95 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return; return;
var json = JsonNode.Parse(content); var json = JsonNode.Parse(content);
var createdDraftId = json?["id"]?.GetValue<string>(); if (bundle?.UIChangeRequest is CreateDraftRequest createDraftRequest)
if (string.IsNullOrWhiteSpace(createdDraftId)) {
var createdDraftId = json?["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(createdDraftId))
return;
var createdConversationId = json?["conversationId"]?.GetValue<string>();
var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy;
await _outlookChangeProcessor.MapLocalDraftAsync(
Account.Id,
localDraft.UniqueId,
createdDraftId,
createdConversationId,
createdConversationId).ConfigureAwait(false);
return; return;
}
var createdConversationId = json?["conversationId"]?.GetValue<string>(); if (bundle?.UIChangeRequest is CreateCalendarEventRequest createCalendarEventRequest)
var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy; {
var createdEventId = json?["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(createdEventId))
return;
await _outlookChangeProcessor.MapLocalDraftAsync( await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false);
Account.Id, }
localDraft.UniqueId,
createdDraftId,
createdConversationId,
createdConversationId).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Draft mapping is best-effort here. Delta sync mapping remains as fallback. _logger.Debug(ex, "Failed to process Outlook create response.");
_logger.Debug(ex, "Failed to map Outlook draft from create-draft response."); }
}
private async Task UploadCalendarEventAttachmentsAsync(CreateCalendarEventRequest request, string remoteEventId, CancellationToken cancellationToken)
{
var attachments = request.ComposeResult.Attachments ?? [];
if (attachments.Count == 0)
return;
var remoteCalendarId = request.AssignedCalendar.RemoteCalendarId;
foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath)))
{
cancellationToken.ThrowIfCancellationRequested();
var contentBytes = await File.ReadAllBytesAsync(attachment.FilePath, cancellationToken).ConfigureAwait(false);
var contentType = MimeTypes.GetMimeType(attachment.FileName ?? attachment.FilePath);
var fileAttachment = new FileAttachment
{
Name = attachment.FileName,
ContentType = contentType,
ContentBytes = contentBytes
};
if (contentBytes.Length <= SimpleAttachmentUploadLimitBytes)
{
await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.PostAsync(fileAttachment, cancellationToken: cancellationToken).ConfigureAwait(false);
continue;
}
if (contentBytes.Length > MaximumUploadSessionAttachmentSizeBytes)
{
var attachmentSizeMb = contentBytes.LongLength / (1024d * 1024d);
var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / (1024d * 1024d);
throw new InvalidOperationException(
$"Attachment '{attachment.FileName}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment.");
}
var sessionBody = new Microsoft.Graph.Me.Calendars.Item.Events.Item.Attachments.CreateUploadSession.CreateUploadSessionPostRequestBody
{
AttachmentItem = new AttachmentItem
{
AttachmentType = AttachmentType.File,
ContentType = contentType,
Name = attachment.FileName,
Size = contentBytes.LongLength
}
};
var uploadSession = await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.CreateUploadSession.PostAsync(sessionBody, cancellationToken: cancellationToken).ConfigureAwait(false);
if (uploadSession?.UploadUrl == null)
{
throw new InvalidOperationException($"Failed to create upload session for attachment '{attachment.FileName}'.");
}
await UploadAttachmentInChunksAsync(uploadSession.UploadUrl, contentBytes, cancellationToken).ConfigureAwait(false);
} }
} }
@@ -2370,52 +2441,51 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
public override List<IRequestBundle<RequestInformation>> CreateCalendarEvent(CreateCalendarEventRequest request) public override List<IRequestBundle<RequestInformation>> CreateCalendarEvent(CreateCalendarEventRequest request)
{ {
var calendarItem = request.Item; var calendarItem = request.PreparedItem;
var attendees = request.Attendees; var attendees = request.PreparedEvent.Attendees;
var reminders = request.PreparedEvent.Reminders;
var calendar = request.AssignedCalendar;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
// Convert CalendarItem to Outlook Event
var outlookEvent = new Microsoft.Graph.Models.Event var outlookEvent = new Microsoft.Graph.Models.Event
{ {
Subject = calendarItem.Title, Subject = calendarItem.Title,
Body = new Microsoft.Graph.Models.ItemBody Body = new Microsoft.Graph.Models.ItemBody
{ {
ContentType = Microsoft.Graph.Models.BodyType.Text, ContentType = Microsoft.Graph.Models.BodyType.Html,
Content = calendarItem.Description Content = calendarItem.Description
}, },
Location = new Microsoft.Graph.Models.Location Location = new Microsoft.Graph.Models.Location
{ {
DisplayName = calendarItem.Location DisplayName = calendarItem.Location
} },
ShowAs = calendarItem.ShowAs switch
{
CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free,
CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative,
CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy,
CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof,
CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere,
_ => Microsoft.Graph.Models.FreeBusyStatus.Busy
},
TransactionId = calendarItem.Id.ToString("N")
}; };
// Set start and end time using DateTimeTimeZone
if (calendarItem.IsAllDayEvent) if (calendarItem.IsAllDayEvent)
{ {
// All-day events
outlookEvent.IsAllDay = true; outlookEvent.IsAllDay = true;
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
{ {
DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"), DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"),
TimeZone = "UTC" TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
}; };
outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone
{ {
DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"), DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"),
TimeZone = "UTC" TimeZone = calendarItem.EndTimeZone ?? calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
}; };
} }
else else
{ {
// Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during creation
outlookEvent.IsAllDay = false; outlookEvent.IsAllDay = false;
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
{ {
@@ -2429,8 +2499,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}; };
} }
// Add attendees if any if (attendees.Count > 0)
if (attendees != null && attendees.Count > 0)
{ {
outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee
{ {
@@ -2443,7 +2512,23 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}).ToList(); }).ToList();
} }
// Create the event using Graph API if (reminders.Count > 0)
{
var reminder = reminders
.OrderBy(reminder => reminder.DurationInSeconds)
.FirstOrDefault(reminder => reminder.ReminderType == CalendarItemReminderType.Popup)
?? reminders.OrderBy(reminder => reminder.DurationInSeconds).First();
outlookEvent.IsReminderOn = true;
outlookEvent.ReminderMinutesBeforeStart = (int)Math.Max(0, reminder.DurationInSeconds / 60);
}
var recurrence = CalendarRecurrenceMapper.CreateOutlookRecurrence(calendarItem);
if (recurrence != null)
{
outlookEvent.Recurrence = recurrence;
}
var createRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events.ToPostRequestInformation(outlookEvent); var createRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events.ToPostRequestInformation(outlookEvent);
return [new HttpRequestBundle<RequestInformation>(createRequest, request)]; return [new HttpRequestBundle<RequestInformation>(createRequest, request)];
@@ -2459,12 +2544,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot accept event without remote event ID"); throw new InvalidOperationException("Cannot accept event without remote event ID");
} }
var acceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Accept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Accept.AcceptPostRequestBody var acceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].Accept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Accept.AcceptPostRequestBody
{ {
Comment = request.ResponseMessage, Comment = request.ResponseMessage,
SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) SendResponse = !string.IsNullOrEmpty(request.ResponseMessage)
@@ -2485,12 +2571,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot decline event without remote event ID"); throw new InvalidOperationException("Cannot decline event without remote event ID");
} }
var declineRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].Decline.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Decline.DeclinePostRequestBody var declineRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].Decline.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.Decline.DeclinePostRequestBody
{ {
Comment = responseMessage, Comment = responseMessage,
SendResponse = !string.IsNullOrEmpty(responseMessage) SendResponse = !string.IsNullOrEmpty(responseMessage)
@@ -2509,12 +2596,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot tentatively accept event without remote event ID"); throw new InvalidOperationException("Cannot tentatively accept event without remote event ID");
} }
var tentativelyAcceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].TentativelyAccept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.TentativelyAccept.TentativelyAcceptPostRequestBody var tentativelyAcceptRequestInfo = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].TentativelyAccept.ToPostRequestInformation(new Microsoft.Graph.Me.Calendars.Item.Events.Item.TentativelyAccept.TentativelyAcceptPostRequestBody
{ {
Comment = request.ResponseMessage, Comment = request.ResponseMessage,
SendResponse = !string.IsNullOrEmpty(request.ResponseMessage) SendResponse = !string.IsNullOrEmpty(request.ResponseMessage)
@@ -2608,7 +2696,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
} }
// Update the event using Graph API // Update the event using Graph API
var updateRequest = _graphClient.Me.Events[calendarItem.RemoteEventId].ToPatchRequestInformation(outlookEvent); var updateRequest = _graphClient.Me.Events[calendarItem.RemoteEventId.GetProviderRemoteEventId()].ToPatchRequestInformation(outlookEvent);
return [new HttpRequestBundle<RequestInformation>(updateRequest, request)]; return [new HttpRequestBundle<RequestInformation>(updateRequest, request)];
} }
@@ -2624,13 +2712,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
throw new InvalidOperationException("Calendar item must have an assigned calendar"); throw new InvalidOperationException("Calendar item must have an assigned calendar");
} }
if (string.IsNullOrEmpty(calendarItem.RemoteEventId)) var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
if (string.IsNullOrEmpty(remoteEventId))
{ {
throw new InvalidOperationException("Cannot delete event without remote event ID"); throw new InvalidOperationException("Cannot delete event without remote event ID");
} }
// Delete the event using Graph API var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].ToDeleteRequestInformation();
var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].ToDeleteRequestInformation();
return [new HttpRequestBundle<RequestInformation>(deleteRequest, request)]; return [new HttpRequestBundle<RequestInformation>(deleteRequest, request)];
} }
+16 -6
View File
@@ -128,9 +128,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
public async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) public async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
{ {
var calendarItem = await Connection.FindWithQueryAsync<CalendarItem>( var calendarItem = await FindCalendarItemByRemoteEventIdAsync(calendarId, calendarRemoteEventId).ConfigureAwait(false);
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?",
calendarId, calendarRemoteEventId);
if (calendarItem == null) return; if (calendarItem == null) return;
@@ -244,9 +242,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
public async Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId) public async Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId)
{ {
var calendarItem = await Connection.FindWithQueryAsync<CalendarItem>( var calendarItem = await FindCalendarItemByRemoteEventIdAsync(accountCalendarId, remoteEventId).ConfigureAwait(false);
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?",
accountCalendarId, remoteEventId);
await LoadAssignedCalendarAsync(calendarItem); await LoadAssignedCalendarAsync(calendarItem);
@@ -481,6 +477,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService
private static DateTime MaxDateTime(DateTime first, DateTime second) private static DateTime MaxDateTime(DateTime first, DateTime second)
=> first >= second ? first : second; => first >= second ? first : second;
private Task<CalendarItem> FindCalendarItemByRemoteEventIdAsync(Guid calendarId, string remoteEventId)
{
if (string.IsNullOrWhiteSpace(remoteEventId))
return Task.FromResult<CalendarItem>(null);
var providerRemoteEventId = remoteEventId.GetProviderRemoteEventId();
return Connection.FindWithQueryAsync<CalendarItem>(
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND (RemoteEventId = ? OR RemoteEventId LIKE ?)",
calendarId,
providerRemoteEventId,
$"{providerRemoteEventId}::%");
}
private sealed class CalendarReminderCandidate private sealed class CalendarReminderCandidate
{ {
public Guid CalendarItemId { get; set; } public Guid CalendarItemId { get; set; }