Event creation.
This commit is contained in:
@@ -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.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Folder;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -107,4 +124,13 @@ public static class SynchronizationActionHelper
|
||||
_ => 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.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Services;
|
||||
using Reminder = Wino.Core.Domain.Entities.Calendar.Reminder;
|
||||
@@ -64,7 +65,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
|
||||
|
||||
// Store dates as UTC in the database
|
||||
savingItem.RemoteEventId = calendarEvent.Id;
|
||||
savingItem.RemoteEventId = calendarEvent.Id.WithClientTrackingId(calendarEvent.TransactionId.GetClientTrackingId());
|
||||
savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime;
|
||||
savingItem.DurationInSeconds = durationInSeconds;
|
||||
|
||||
|
||||
@@ -1,35 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Helpers;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// After successful creation, we need to resync to get the remote event ID.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
public override int ResynchronizationDelay => 5000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Notify UI that the event was created locally
|
||||
if (Item == null)
|
||||
return;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult)
|
||||
=> string.IsNullOrWhiteSpace(composeResult?.Recurrence);
|
||||
}
|
||||
|
||||
@@ -27,16 +27,19 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailDialogService _dialogService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
|
||||
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
|
||||
IFolderService folderService,
|
||||
IMailDialogService dialogService,
|
||||
IAccountService accountService)
|
||||
IAccountService accountService,
|
||||
ICalendarService calendarService)
|
||||
{
|
||||
_winoRequestProcessor = winoRequestProcessor;
|
||||
_folderService = folderService;
|
||||
_dialogService = dialogService;
|
||||
_accountService = accountService;
|
||||
_calendarService = calendarService;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(MailOperationPreperationRequest request)
|
||||
@@ -159,9 +162,12 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
|
||||
public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
||||
{
|
||||
if (calendarPreparationRequest == null)
|
||||
return;
|
||||
|
||||
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.AcceptEvent => new AcceptEventRequest(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.")
|
||||
};
|
||||
|
||||
await QueueRequestAsync(request, calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId);
|
||||
await QueueCalendarSynchronizationAsync(calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId);
|
||||
if (request == null)
|
||||
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)
|
||||
|
||||
@@ -144,7 +144,10 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
|
||||
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
var calendarItem = request.Item;
|
||||
var attendees = request.Attendees;
|
||||
var calendarItem = request.PreparedItem;
|
||||
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
|
||||
{
|
||||
Id = calendarItem.Id.ToString("N").ToLowerInvariant(),
|
||||
Summary = calendarItem.Title,
|
||||
Description = calendarItem.Description,
|
||||
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)
|
||||
{
|
||||
// All-day events use Date instead of DateTime
|
||||
googleEvent.Start = new EventDateTime
|
||||
{
|
||||
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),
|
||||
Date = calendarItem.StartDate.ToString("yyyy-MM-dd"),
|
||||
TimeZone = calendarItem.StartTimeZone
|
||||
};
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// Add attendees if any
|
||||
if (attendees != null && attendees.Count > 0)
|
||||
if (attendees.Count > 0)
|
||||
{
|
||||
googleEvent.Attendees = attendees.Select(a => new EventAttendee
|
||||
{
|
||||
@@ -2334,8 +2328,32 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}).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);
|
||||
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)];
|
||||
}
|
||||
@@ -2350,7 +2368,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
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");
|
||||
}
|
||||
@@ -2375,7 +2394,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
ResponseStatus = "accepted"
|
||||
}
|
||||
}
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
}, calendar.RemoteCalendarId, remoteEventId);
|
||||
|
||||
// Send updates to other attendees if there's a message
|
||||
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");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
if (string.IsNullOrEmpty(remoteEventId))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot decline event without remote event ID");
|
||||
}
|
||||
@@ -2413,7 +2433,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
Comment = request.ResponseMessage
|
||||
}
|
||||
}
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
}, calendar.RemoteCalendarId, remoteEventId);
|
||||
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? 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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -2450,7 +2471,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
Comment = request.ResponseMessage
|
||||
}
|
||||
}
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
}, calendar.RemoteCalendarId, remoteEventId);
|
||||
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? 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");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
if (string.IsNullOrEmpty(remoteEventId))
|
||||
{
|
||||
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
|
||||
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
|
||||
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");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
if (string.IsNullOrEmpty(remoteEventId))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot delete event without remote event ID");
|
||||
}
|
||||
|
||||
// Delete the event using Google Calendar API
|
||||
var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, remoteEventId);
|
||||
|
||||
// Send cancellation notifications to attendees
|
||||
deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All;
|
||||
@@ -2576,4 +2598,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
_calendarService.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.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Connectivity;
|
||||
@@ -95,7 +96,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -1531,32 +1532,38 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
private readonly MailAccount _account;
|
||||
private readonly IImapChangeProcessor _changeProcessor;
|
||||
private readonly ICalendarService _calendarService;
|
||||
private readonly string _applicationDataFolderPath;
|
||||
private readonly string _resourceScheme;
|
||||
|
||||
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;
|
||||
_changeProcessor = changeProcessor;
|
||||
_calendarService = calendarService;
|
||||
_applicationDataFolderPath = applicationDataFolderPath;
|
||||
_resourceScheme = resourceScheme;
|
||||
}
|
||||
|
||||
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");
|
||||
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
|
||||
|
||||
var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false);
|
||||
|
||||
if (existing == null)
|
||||
await _calendarService.CreateNewCalendarItemAsync(item, request.Attendees).ConfigureAwait(false);
|
||||
await _calendarService.CreateNewCalendarItemAsync(item, attendees).ConfigureAwait(false);
|
||||
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)
|
||||
@@ -1616,6 +1623,45 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
DateTimeOffset.UtcNow.ToString("O"),
|
||||
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
|
||||
@@ -1640,7 +1686,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}
|
||||
|
||||
public Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
|
||||
=> UpsertCalendarEventAsync(request.Item, request.Attendees);
|
||||
=> UpsertCalendarEventAsync(request.PreparedItem, request.PreparedEvent.Attendees);
|
||||
|
||||
public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
|
||||
=> UpsertCalendarEventAsync(request.Item, request.Attendees);
|
||||
@@ -1654,7 +1700,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}
|
||||
|
||||
await _calDavClient
|
||||
.DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId)
|
||||
.DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId.GetProviderRemoteEventId())
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1689,7 +1735,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
var icsContent = BuildIcsContent(item, attendees);
|
||||
|
||||
await _calDavClient
|
||||
.UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId, icsContent)
|
||||
.UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId.GetProviderRemoteEventId(), icsContent)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
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.Extensions;
|
||||
using Wino.Core.Http;
|
||||
using Wino.Core.Helpers;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
@@ -1634,11 +1636,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
try
|
||||
{
|
||||
var calendar = calendarItem.AssignedCalendar;
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
|
||||
// First, get the attachment metadata to retrieve contentBytes for FileAttachment
|
||||
var attachmentItem = await _graphClient.Me
|
||||
.Calendars[calendar.RemoteCalendarId]
|
||||
.Events[calendarItem.RemoteEventId]
|
||||
.Events[remoteEventId]
|
||||
.Attachments[attachment.RemoteAttachmentId]
|
||||
.GetAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -1879,9 +1882,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
private async Task HandleSuccessfulResponseAsync(IRequestBundle<RequestInformation> bundle, HttpResponseMessage response)
|
||||
{
|
||||
if (bundle?.UIChangeRequest is not CreateDraftRequest createDraftRequest)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -1889,24 +1889,95 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
return;
|
||||
|
||||
var json = JsonNode.Parse(content);
|
||||
var createdDraftId = json?["id"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(createdDraftId))
|
||||
if (bundle?.UIChangeRequest is CreateDraftRequest createDraftRequest)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
var createdConversationId = json?["conversationId"]?.GetValue<string>();
|
||||
var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy;
|
||||
if (bundle?.UIChangeRequest is CreateCalendarEventRequest createCalendarEventRequest)
|
||||
{
|
||||
var createdEventId = json?["id"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(createdEventId))
|
||||
return;
|
||||
|
||||
await _outlookChangeProcessor.MapLocalDraftAsync(
|
||||
Account.Id,
|
||||
localDraft.UniqueId,
|
||||
createdDraftId,
|
||||
createdConversationId,
|
||||
createdConversationId).ConfigureAwait(false);
|
||||
await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Draft mapping is best-effort here. Delta sync mapping remains as fallback.
|
||||
_logger.Debug(ex, "Failed to map Outlook draft from create-draft response.");
|
||||
_logger.Debug(ex, "Failed to process Outlook create 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)
|
||||
{
|
||||
var calendarItem = request.Item;
|
||||
var attendees = request.Attendees;
|
||||
var calendarItem = request.PreparedItem;
|
||||
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
|
||||
{
|
||||
Subject = calendarItem.Title,
|
||||
Body = new Microsoft.Graph.Models.ItemBody
|
||||
{
|
||||
ContentType = Microsoft.Graph.Models.BodyType.Text,
|
||||
ContentType = Microsoft.Graph.Models.BodyType.Html,
|
||||
Content = calendarItem.Description
|
||||
},
|
||||
Location = new Microsoft.Graph.Models.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)
|
||||
{
|
||||
// All-day events
|
||||
outlookEvent.IsAllDay = true;
|
||||
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
|
||||
{
|
||||
DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"),
|
||||
TimeZone = "UTC"
|
||||
TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
|
||||
};
|
||||
outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone
|
||||
{
|
||||
DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"),
|
||||
TimeZone = "UTC"
|
||||
TimeZone = calendarItem.EndTimeZone ?? calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
|
||||
};
|
||||
}
|
||||
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.Start = new Microsoft.Graph.Models.DateTimeTimeZone
|
||||
{
|
||||
@@ -2429,8 +2499,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
};
|
||||
}
|
||||
|
||||
// Add attendees if any
|
||||
if (attendees != null && attendees.Count > 0)
|
||||
if (attendees.Count > 0)
|
||||
{
|
||||
outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee
|
||||
{
|
||||
@@ -2443,7 +2512,23 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
}).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);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
if (string.IsNullOrEmpty(remoteEventId))
|
||||
{
|
||||
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,
|
||||
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");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
var remoteEventId = calendarItem.RemoteEventId.GetProviderRemoteEventId();
|
||||
if (string.IsNullOrEmpty(remoteEventId))
|
||||
{
|
||||
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,
|
||||
SendResponse = !string.IsNullOrEmpty(responseMessage)
|
||||
@@ -2509,12 +2596,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
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");
|
||||
}
|
||||
|
||||
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,
|
||||
SendResponse = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
@@ -2608,7 +2696,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
}
|
||||
|
||||
// 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)];
|
||||
}
|
||||
@@ -2624,13 +2712,13 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
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");
|
||||
}
|
||||
|
||||
// Delete the event using Graph API
|
||||
var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].ToDeleteRequestInformation();
|
||||
var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[remoteEventId].ToDeleteRequestInformation();
|
||||
|
||||
return [new HttpRequestBundle<RequestInformation>(deleteRequest, request)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user