Event creation.
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 _);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user