From 4603b1fb140b4f5b4ff26d2ab72e8161f248a1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 3 Jan 2026 23:59:37 +0100 Subject: [PATCH] Calendar attachments. --- .../Data/CalendarAttachmentViewModel.cs | 71 ++++++++ .../EventDetailsPageViewModel.cs | 161 +++++++++++++++++- .../Entities/Calendar/CalendarAttachment.cs | 55 ++++++ .../Interfaces/ICalendarService.cs | 24 +++ .../Interfaces/INativeAppService.cs | 5 + .../Interfaces/IWinoSynchronizerBase.cs | 2 + .../Translations/en_US/resources.json | 7 + .../Processors/GmailChangeProcessor.cs | 55 ++++++ .../Processors/OutlookChangeProcessor.cs | 27 +++ Wino.Core/Services/SynchronizationManager.cs | 47 +++++ Wino.Core/Synchronizers/GmailSynchronizer.cs | 33 ++++ Wino.Core/Synchronizers/ImapSynchronizer.cs | 12 ++ .../Synchronizers/OutlookSynchronizer.cs | 64 ++++++- Wino.Core/Synchronizers/WinoSynchronizer.cs | 13 ++ .../Helpers/CalendarXamlHelpers.cs | 8 +- Wino.Mail.WinUI/Services/NativeAppService.cs | 10 ++ .../Views/Calendar/EventDetailsPage.xaml | 125 ++++++++++++-- .../Views/Calendar/EventDetailsPage.xaml.cs | 25 +++ Wino.Services/CalendarService.cs | 34 ++++ Wino.Services/DatabaseService.cs | 1 + 20 files changed, 758 insertions(+), 21 deletions(-) create mode 100644 Wino.Calendar.ViewModels/Data/CalendarAttachmentViewModel.cs create mode 100644 Wino.Core.Domain/Entities/Calendar/CalendarAttachment.cs diff --git a/Wino.Calendar.ViewModels/Data/CalendarAttachmentViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarAttachmentViewModel.cs new file mode 100644 index 00000000..3202909c --- /dev/null +++ b/Wino.Calendar.ViewModels/Data/CalendarAttachmentViewModel.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Extensions; + +namespace Wino.Calendar.ViewModels.Data; + +public partial class CalendarAttachmentViewModel : ObservableObject +{ + public CalendarAttachment Attachment { get; } + + public Guid Id => Attachment.Id; + public string FileName => Attachment.FileName; + public string ReadableSize { get; } + public MailAttachmentType AttachmentType { get; } + public bool IsDownloaded => Attachment.IsDownloaded; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + public CalendarAttachmentViewModel(CalendarAttachment attachment) + { + Attachment = attachment; + ReadableSize = attachment.Size.GetBytesReadable(); + + var extension = Path.GetExtension(FileName); + AttachmentType = GetAttachmentType(extension); + } + + private MailAttachmentType GetAttachmentType(string extension) + { + if (string.IsNullOrEmpty(extension)) + return MailAttachmentType.None; + + switch (extension.ToLower()) + { + case ".exe": + return MailAttachmentType.Executable; + case ".rar": + return MailAttachmentType.RarArchive; + case ".zip": + return MailAttachmentType.Archive; + case ".ogg": + case ".mp3": + case ".wav": + case ".aac": + case ".alac": + return MailAttachmentType.Audio; + case ".mp4": + case ".wmv": + case ".avi": + case ".flv": + return MailAttachmentType.Video; + case ".pdf": + return MailAttachmentType.PDF; + case ".htm": + case ".html": + return MailAttachmentType.HTML; + case ".png": + case ".jpg": + case ".jpeg": + case ".gif": + case ".jiff": + return MailAttachmentType.Image; + default: + return MailAttachmentType.Other; + } + } +} diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index ee2e824a..1763173b 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Serilog; using Wino.Calendar.ViewModels.Data; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; @@ -14,6 +16,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Services; using Wino.Core.ViewModels; using Wino.Messaging.Client.Calendar; @@ -35,11 +38,12 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel [ObservableProperty] public partial bool IsDarkWebviewRenderer { get; set; } + public ObservableCollection Attachments { get; } = new ObservableCollection(); + /// /// Returns true if the current event has attachments. - /// Currently always returns false until attachments are implemented. /// - public bool HasAttachments => false; // TODO: Implement when CalendarItem attachments are added + public bool HasAttachments => Attachments.Count > 0; #region Details @@ -200,6 +204,24 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel var attendees = await _calendarService.GetAttendeesAsync(currentEventItem.EventTrackingId); + // Check if organizer is in the attendees list + var hasOrganizerInList = attendees.Any(a => a.IsOrganizer); + + // If the user is the organizer but not in attendees list, add them + if (!hasOrganizerInList && !string.IsNullOrEmpty(currentEventItem.OrganizerEmail)) + { + var organizerAttendee = new CalendarEventAttendee + { + Id = Guid.NewGuid(), + CalendarItemId = currentEventItem.Id, + Name = currentEventItem.OrganizerDisplayName ?? currentEventItem.OrganizerEmail, + Email = currentEventItem.OrganizerEmail, + IsOrganizer = true, + AttendenceStatus = AttendeeStatus.Accepted + }; + CurrentEvent.Attendees.Add(organizerAttendee); + } + foreach (var item in attendees) { CurrentEvent.Attendees.Add(item); @@ -208,6 +230,9 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel // Load reminders for this calendar item Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId); InitializeReminderOptions(); + + // Load attachments + await LoadAttachmentsAsync(currentEventItem.EventTrackingId); } catch (Exception ex) { @@ -215,6 +240,27 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel } } + private async Task LoadAttachmentsAsync(Guid calendarItemId) + { + Attachments.Clear(); + + try + { + var attachments = await _calendarService.GetAttachmentsAsync(calendarItemId); + + foreach (var attachment in attachments) + { + Attachments.Add(new CalendarAttachmentViewModel(attachment)); + } + + OnPropertyChanged(nameof(HasAttachments)); + } + catch (Exception ex) + { + Debug.WriteLine($"Error loading attachments: {ex.Message}"); + } + } + private void InitializeReminderOptions() { ReminderOptions.Clear(); @@ -429,6 +475,117 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel Debug.WriteLine($"Error loading series: {ex.Message}"); } } + + [RelayCommand] + private async Task OpenAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel) + { + if (attachmentViewModel == null || CurrentEvent?.CalendarItem == null) return; + + try + { + attachmentViewModel.IsBusy = true; + + // If not downloaded, download it first + if (!attachmentViewModel.IsDownloaded) + { + await DownloadAttachmentAsync(attachmentViewModel); + } + + // Launch the file + if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) && + File.Exists(attachmentViewModel.Attachment.LocalFilePath)) + { + await _nativeAppService.LaunchFileAsync(attachmentViewModel.Attachment.LocalFilePath); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to open calendar attachment."); + _dialogService.InfoBarMessage( + Translator.Info_AttachmentOpenFailedTitle, + Translator.Info_AttachmentOpenFailedMessage, + InfoBarMessageType.Error); + } + finally + { + attachmentViewModel.IsBusy = false; + } + } + + [RelayCommand] + private async Task SaveAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel) + { + if (attachmentViewModel == null) return; + + try + { + attachmentViewModel.IsBusy = true; + + var pickedPath = await _dialogService.PickWindowsFolderAsync(); + if (string.IsNullOrEmpty(pickedPath)) return; + + // Download if not already downloaded + if (!attachmentViewModel.IsDownloaded) + { + await DownloadAttachmentAsync(attachmentViewModel); + } + + // Copy to selected location + if (!string.IsNullOrEmpty(attachmentViewModel.Attachment.LocalFilePath) && + File.Exists(attachmentViewModel.Attachment.LocalFilePath)) + { + var destinationPath = Path.Combine(pickedPath, attachmentViewModel.FileName); + File.Copy(attachmentViewModel.Attachment.LocalFilePath, destinationPath, overwrite: true); + + _dialogService.InfoBarMessage( + Translator.Info_AttachmentSaveSuccessTitle, + Translator.Info_AttachmentSaveSuccessMessage, + InfoBarMessageType.Success); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to save calendar attachment."); + _dialogService.InfoBarMessage( + Translator.Info_AttachmentSaveFailedTitle, + Translator.Info_AttachmentSaveFailedMessage, + InfoBarMessageType.Error); + } + finally + { + attachmentViewModel.IsBusy = false; + } + } + + private async Task DownloadAttachmentAsync(CalendarAttachmentViewModel attachmentViewModel) + { + if (CurrentEvent?.CalendarItem == null) return; + + // Create attachments folder for this calendar item + var attachmentsFolder = Path.Combine( + _nativeAppService.GetCalendarAttachmentsFolderPath(), + CurrentEvent.CalendarItem.Id.ToString()); + + Directory.CreateDirectory(attachmentsFolder); + + var localFilePath = Path.Combine(attachmentsFolder, attachmentViewModel.FileName); + + // Download attachment using synchronizer + await SynchronizationManager.Instance.DownloadCalendarAttachmentAsync( + CurrentEvent.CalendarItem, + attachmentViewModel.Attachment, + localFilePath); + + // Mark as downloaded + await _calendarService.MarkAttachmentDownloadedAsync( + attachmentViewModel.Id, + localFilePath); + + // Update view model + attachmentViewModel.Attachment.IsDownloaded = true; + attachmentViewModel.Attachment.LocalFilePath = localFilePath; + OnPropertyChanged(nameof(attachmentViewModel.IsDownloaded)); + } } public partial class ReminderOption : ObservableObject diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarAttachment.cs b/Wino.Core.Domain/Entities/Calendar/CalendarAttachment.cs new file mode 100644 index 00000000..477acd38 --- /dev/null +++ b/Wino.Core.Domain/Entities/Calendar/CalendarAttachment.cs @@ -0,0 +1,55 @@ +using System; +using SQLite; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Entities.Calendar; + +/// +/// Represents metadata for calendar event attachments. +/// Actual file content is downloaded on-demand. +/// +public class CalendarAttachment +{ + [PrimaryKey] + public Guid Id { get; set; } + + /// + /// The calendar item this attachment belongs to. + /// + public Guid CalendarItemId { get; set; } + + /// + /// Remote identifier for the attachment from the provider (Outlook, Gmail, etc.). + /// + public string RemoteAttachmentId { get; set; } + + /// + /// File name of the attachment. + /// + public string FileName { get; set; } + + /// + /// Size of the attachment in bytes. + /// + public long Size { get; set; } + + /// + /// MIME content type (e.g., "application/pdf", "image/png"). + /// + public string ContentType { get; set; } + + /// + /// Whether the attachment has been downloaded to local storage. + /// + public bool IsDownloaded { get; set; } + + /// + /// Local file path where the attachment is stored (if downloaded). + /// + public string LocalFilePath { get; set; } + + /// + /// When the attachment was last modified. + /// + public DateTimeOffset LastModified { get; set; } +} diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs index bd958f73..694ea7a7 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs @@ -54,4 +54,28 @@ public interface ICalendarService /// Gets predefined reminder options in minutes (1 Hour, 30 Min, 15 Min, 5 Min, 1 Min). /// int[] GetPredefinedReminderMinutes(); + + #region Attachments + + /// + /// Gets all attachments for a calendar event. + /// + Task> GetAttachmentsAsync(Guid calendarItemId); + + /// + /// Inserts or updates calendar attachments. + /// + Task InsertOrReplaceAttachmentsAsync(List attachments); + + /// + /// Marks an attachment as downloaded and updates its local file path. + /// + Task MarkAttachmentDownloadedAsync(Guid attachmentId, string localFilePath); + + /// + /// Deletes all attachments for a calendar item. + /// + Task DeleteAttachmentsAsync(Guid calendarItemId); + + #endregion } diff --git a/Wino.Core.Domain/Interfaces/INativeAppService.cs b/Wino.Core.Domain/Interfaces/INativeAppService.cs index cfbea72f..2c5c4662 100644 --- a/Wino.Core.Domain/Interfaces/INativeAppService.cs +++ b/Wino.Core.Domain/Interfaces/INativeAppService.cs @@ -22,4 +22,9 @@ public interface INativeAppService /// This is used to display WAM broker dialog on running UWP app called by a windowless server code. /// Func GetCoreWindowHwnd { get; set; } + + /// + /// Gets the folder path where calendar attachments are stored. + /// + string GetCalendarAttachmentsFolderPath(); } diff --git a/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs b/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs index 1ff8e530..f19c1e5e 100644 --- a/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs +++ b/Wino.Core.Domain/Interfaces/IWinoSynchronizerBase.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using MailKit; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Synchronization; @@ -46,4 +47,5 @@ public interface IWinoSynchronizerBase : IBaseSynchronizer /// Cancellation token. /// Search results after downloading missing mail copies from server. Task> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default); + Task DownloadCalendarAttachmentAsync(CalendarItem calendarItem, CalendarAttachment attachment, string localFilePath, CancellationToken cancellationToken); } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index a25d0c29..d1af52a4 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -112,6 +112,13 @@ "CalendarEventDetails_ReadOnlyEvent": "Read-only event", "CalendarEventDetails_Reminder": "Reminder", "CalendarEventDetails_ShowAs": "Show as", + "CalendarEventResponse_Accept": "Accept", + "CalendarEventResponse_AcceptedResponse": "Accepted", + "CalendarEventResponse_Decline": "Decline", + "CalendarEventResponse_DeclinedResponse": "Declined", + "CalendarEventResponse_NotResponded": "Not Responded", + "CalendarEventResponse_Tentative": "Tentative", + "CalendarEventResponse_TentativeResponse": "Tentatively Accepted", "CalendarItem_DetailsPopup_JoinOnline": "Join online", "CalendarItem_DetailsPopup_ViewEventButton": "View event", "CalendarItem_DetailsPopup_ViewSeriesButton": "View series", diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs index e975aba7..06735274 100644 --- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using Google.Apis.Calendar.v3.Data; using Serilog; @@ -250,10 +251,37 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso } } + // Prepare attachments metadata from Gmail event + List attachments = null; + if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0) + { + attachments = calendarEvent.Attachments + .Where(a => a != null && !string.IsNullOrEmpty(a.Title)) + .Select(a => new CalendarAttachment + { + Id = Guid.NewGuid(), + CalendarItemId = calendarItem.Id, + RemoteAttachmentId = a.FileId ?? a.FileUrl, // Gmail uses FileId or FileUrl + FileName = a.Title, + Size = 0, // Gmail API doesn't provide size in Event.Attachment + ContentType = a.MimeType ?? "application/octet-stream", + IsDownloaded = false, + LocalFilePath = null, + LastModified = DateTimeOffset.UtcNow + }) + .ToList(); + } + await CalendarService.CreateNewCalendarItemAsync(calendarItem, attendees); // Save reminders separately await CalendarService.SaveRemindersAsync(calendarItem.Id, reminders).ConfigureAwait(false); + + // Save attachments metadata separately + if (attachments != null && attachments.Count > 0) + { + await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false); + } } else { @@ -315,6 +343,33 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso // Save reminders await CalendarService.SaveRemindersAsync(existingCalendarItem.Id, reminders).ConfigureAwait(false); + + // Prepare attachments metadata from Gmail event for update + List attachments = null; + if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0) + { + attachments = calendarEvent.Attachments + .Where(a => a != null && !string.IsNullOrEmpty(a.Title)) + .Select(a => new CalendarAttachment + { + Id = Guid.NewGuid(), + CalendarItemId = existingCalendarItem.Id, + RemoteAttachmentId = a.FileId ?? a.FileUrl, + FileName = a.Title, + Size = 0, + ContentType = a.MimeType ?? "application/octet-stream", + IsDownloaded = false, + LocalFilePath = null, + LastModified = DateTimeOffset.UtcNow + }) + .ToList(); + } + + // Save attachments metadata + if (attachments != null && attachments.Count > 0) + { + await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false); + } } // Upsert the event. diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 60ce4103..1ce3e3c2 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -195,6 +195,27 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, }; } + // Prepare attachments metadata from Outlook event + List attachments = null; + if (calendarEvent.HasAttachments.GetValueOrDefault() && calendarEvent.Attachments != null) + { + attachments = calendarEvent.Attachments + .Where(a => a != null && !string.IsNullOrEmpty(a.Name)) + .Select(a => new CalendarAttachment + { + Id = Guid.NewGuid(), + CalendarItemId = savingItemId, + RemoteAttachmentId = a.Id, + FileName = a.Name, + Size = a.Size ?? 0, + ContentType = a.ContentType ?? "application/octet-stream", + IsDownloaded = false, + LocalFilePath = null, + LastModified = calendarEvent.LastModifiedDateTime ?? DateTimeOffset.UtcNow + }) + .ToList(); + } + // Use CalendarService to create or update the event if (isNewItem) { @@ -209,5 +230,11 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, // Save reminders separately await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false); + + // Save attachments metadata separately + if (attachments != null && attachments.Count > 0) + { + await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false); + } } } diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 4fae1313..67908b0a 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -412,6 +412,53 @@ public class SynchronizationManager : ISynchronizationManager } } + /// + /// Downloads a calendar attachment using the appropriate synchronizer. + /// + public async Task DownloadCalendarAttachmentAsync( + Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem, + Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment, + string localFilePath, + CancellationToken cancellationToken = default) + { + EnsureInitialized(); + + if (calendarItem == null) + throw new ArgumentNullException(nameof(calendarItem)); + + if (attachment == null) + throw new ArgumentNullException(nameof(attachment)); + + var accountId = calendarItem.AssignedCalendar?.AccountId ?? Guid.Empty; + if (accountId == Guid.Empty) + throw new InvalidOperationException("Calendar item does not have an assigned account."); + + var synchronizer = await GetOrCreateSynchronizerAsync(accountId); + + if (synchronizer == null) + { + _logger.Error("Could not find or create synchronizer for account {AccountId} to download calendar attachment", accountId); + throw new InvalidOperationException("No synchronizer available for downloading calendar attachment."); + } + + _logger.Debug("Downloading calendar attachment {AttachmentId} for calendar item {CalendarItemId}", + attachment.Id, calendarItem.Id); + + try + { + await synchronizer.DownloadCalendarAttachmentAsync( + calendarItem, + attachment, + localFilePath, + cancellationToken); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to download calendar attachment {AttachmentId}", attachment.Id); + throw; + } + } + /// /// Creates a new synchronizer for a newly added account. /// diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index a169aee3..15b814da 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1242,6 +1242,39 @@ public class GmailSynchronizer : WinoSynchronizer> RenameFolder(RenameFolderRequest request) { var label = new Label() diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index f6ec22ad..6d9198e5 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -243,6 +243,18 @@ public class ImapSynchronizer : WinoSynchronizer> RenameFolder(RenameFolderRequest request) { return CreateSingleTaskBundle(async (client, item) => diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 9aa09f0c..137a519e 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1337,6 +1337,61 @@ public class OutlookSynchronizer : WinoSynchronizer> RenameFolder(RenameFolderRequest request) { var requestBody = new MailFolder @@ -1693,9 +1748,12 @@ public class OutlookSynchronizer : WinoSynchronizer + { + // Expand attachments but only get metadata, not the full content + requestConfiguration.QueryParameters.Expand = new[] { "attachments($select=id,name,contentType,size,isInline)" }; + }, cancellationToken: cancellationToken).ConfigureAwait(false); await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false); } catch (Exception ex) diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index 54562779..f5d8dd3a 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -526,6 +526,19 @@ public abstract class WinoSynchronizerCancellation token. public virtual Task DownloadMissingMimeMessageAsync(MailCopy mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + /// + /// Downloads a calendar attachment from the provider. + /// + /// Calendar item the attachment belongs to. + /// Attachment metadata to download. + /// Local file path to save the attachment to. + /// Cancellation token. + public virtual Task DownloadCalendarAttachmentAsync( + Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem, + Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment, + string localFilePath, + CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + /// /// Performs an online search for the given query text in the given folders. /// Downloads the missing messages from the server. diff --git a/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs index 612c47cc..8db6c8b5 100644 --- a/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs +++ b/Wino.Mail.WinUI/Helpers/CalendarXamlHelpers.cs @@ -128,13 +128,11 @@ public static class CalendarXamlHelpers /// /// Returns visibility for attendee status badge. - /// Only shows status for non-organizers and when status is not NeedsAction. + /// Always shows status for all attendees. /// public static Microsoft.UI.Xaml.Visibility GetAttendeeStatusVisibility(AttendeeStatus status) { - // Don't show "Needs Action" status as it's the default - return status == AttendeeStatus.NeedsAction - ? Microsoft.UI.Xaml.Visibility.Collapsed - : Microsoft.UI.Xaml.Visibility.Visible; + // Always show status + return Microsoft.UI.Xaml.Visibility.Visible; } } diff --git a/Wino.Mail.WinUI/Services/NativeAppService.cs b/Wino.Mail.WinUI/Services/NativeAppService.cs index b8f8fde2..bf030f8d 100644 --- a/Wino.Mail.WinUI/Services/NativeAppService.cs +++ b/Wino.Mail.WinUI/Services/NativeAppService.cs @@ -103,4 +103,14 @@ public class NativeAppService : INativeAppService //await taskbarManager.RequestPinCurrentAppAsync(); } + + public bool IsAppRunningInBackground() + => !Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread().HasThreadAccess; + + public string GetCalendarAttachmentsFolderPath() + { + var attachmentsFolder = System.IO.Path.Combine(ApplicationData.Current.LocalFolder.Path, "CalendarAttachments"); + System.IO.Directory.CreateDirectory(attachmentsFolder); + return attachmentsFolder; + } } diff --git a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml index 63472a39..d9851332 100644 --- a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml @@ -10,6 +10,7 @@ xmlns:coreControls="using:Wino.Mail.WinUI.Controls" xmlns:ctControls="using:CommunityToolkit.WinUI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:data="using:Wino.Calendar.ViewModels.Data" xmlns:domain="using:Wino.Core.Domain" xmlns:enums="using:Wino.Core.Domain.Enums" xmlns:helpers="using:Wino.Helpers" @@ -444,6 +445,7 @@ + - + - - - + + + + @@ -508,6 +517,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml.cs index b7cd614e..5a6d064b 100644 --- a/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Calendar/EventDetailsPage.xaml.cs @@ -7,6 +7,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; using Microsoft.Web.WebView2.Core; using Windows.System; +using Wino.Calendar.ViewModels.Data; using Wino.Core.Domain; using Wino.Core.Domain.Interfaces; using Wino.Mail.WinUI; @@ -203,4 +204,28 @@ public sealed partial class EventDetailsPage : EventDetailsPageAbstract, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); } + + private void AttachmentClicked(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is CalendarAttachmentViewModel attachmentViewModel) + { + ViewModel?.OpenAttachmentCommand.Execute(attachmentViewModel); + } + } + + private void OpenCalendarAttachment_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + if (sender is MenuFlyoutItem item && item.CommandParameter is CalendarAttachmentViewModel attachment) + { + ViewModel?.OpenAttachmentCommand.Execute(attachment); + } + } + + private void SaveCalendarAttachment_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + if (sender is MenuFlyoutItem item && item.CommandParameter is CalendarAttachmentViewModel attachment) + { + ViewModel?.SaveAttachmentCommand.Execute(attachment); + } + } } diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 5f3b159d..a8d8cee9 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -480,4 +480,38 @@ public class CalendarService : BaseDatabaseService, ICalendarService } }); } + + #region Attachments + + public Task> GetAttachmentsAsync(Guid calendarItemId) + => Connection.Table().Where(x => x.CalendarItemId == calendarItemId).ToListAsync(); + + public async Task InsertOrReplaceAttachmentsAsync(List attachments) + { + if (attachments == null || attachments.Count == 0) return; + + foreach (var item in attachments) + { + await Connection.InsertOrReplaceAsync(item, typeof(CalendarAttachment)); + } + } + + public async Task MarkAttachmentDownloadedAsync(Guid attachmentId, string localFilePath) + { + var attachment = await Connection.Table().FirstOrDefaultAsync(x => x.Id == attachmentId); + + if (attachment == null) return; + + attachment.IsDownloaded = true; + attachment.LocalFilePath = localFilePath; + + await Connection.UpdateAsync(attachment, typeof(CalendarAttachment)); + } + + public async Task DeleteAttachmentsAsync(Guid calendarItemId) + { + await Connection.ExecuteAsync("DELETE FROM CalendarAttachment WHERE CalendarItemId = ?", calendarItemId); + } + + #endregion } diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index a6c1c39e..afc249e8 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -59,6 +59,7 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), + Connection.CreateTableAsync(), Connection.CreateTableAsync() ); }