diff --git a/Directory.Packages.props b/Directory.Packages.props index 81cc018f..b9ed3d1e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + diff --git a/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md b/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md new file mode 100644 index 00000000..702848a0 --- /dev/null +++ b/OUTLOOK_QUEUE_SYNC_IMPLEMENTATION.md @@ -0,0 +1,252 @@ +# Outlook Queue-Based Synchronization Implementation + +## Overview + +This document describes the implementation of the queue-based, metadata-only synchronization system for Outlook, mirroring the approach used in Gmail but adapted for Outlook's per-folder synchronization model. + +## Key Differences from Gmail + +1. **Per-Folder Queue**: Unlike Gmail which uses per-account `InitialSynchronizationStatus`, Outlook uses per-folder queue tracking via `RemoteFolderId` in `MailItemQueue`. +2. **Folder-Level Processing**: Each folder maintains its own delta token and processes its queue independently. +3. **No Account-Level Status**: Outlook doesn't use `Account.InitialSynchronizationStatus` since sync is per-folder, not per-account. + +## Architecture Changes + +### 1. MailItemQueue Entity Enhancement + +**File**: `Wino.Core.Domain\Entities\Mail\MailItemQueue.cs` + +Added `RemoteFolderId` property to support per-folder queue tracking: + +```csharp +public string RemoteFolderId { get; set; } // For Outlook per-folder sync +``` + +### 2. Service Layer Updates + +**Files**: +- `Wino.Core.Domain\Interfaces\IMailService.cs` +- `Wino.Services\MailService.cs` +- `Wino.Core\Integration\Processors\DefaultChangeProcessor.cs` + +Added new methods for folder-specific queue operations: + +```csharp +Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take); +Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId); +``` + +### 3. OutlookSynchronizer Redesign + +**File**: `Wino.Core\Synchronizers\OutlookSynchronizer.cs` + +#### New Methods + +1. **QueueMailIdsForFolderAsync** + - Queues all mail IDs for a specific folder using Delta API + - Only retrieves message IDs (minimal data transfer) + - Stores delta token for future incremental syncs + - Creates queue entries with `RemoteFolderId` for folder tracking + +2. **ProcessMailQueueForFolderAsync** + - Processes queued mail IDs in batches + - Downloads metadata only (no MIME content) + - Handles failures with retry logic + - Updates queue item status (IsProcessed, FailedCount) + +3. **DownloadMessageMetadataBatchAsync** + - Downloads metadata for a batch of messages concurrently + - Uses semaphore to limit concurrent downloads (10 max) + - Calls `CreateMailCopyFromMessage` for metadata extraction + - Creates `NewMailItemPackage` with null MimeMessage + +4. **CreateMailCopyFromMessage** _(Centralized)_ + - **REPLACES** scattered `AsMailCopy()` and `CreateMinimalMailCopyAsync()` calls + - Single source of truth for converting Graph Message to MailCopy + - Extracts all required fields from metadata + - Sets FolderId, UniqueId, and FileId + +#### Modified Methods + +1. **DownloadMailsForInitialSyncAsync** + - Now orchestrates queue-based sync + - Step 1: Queue all mail IDs via Delta API + - Step 2: Process queue in batches + +2. **ProcessDeltaChangesAndDownloadMailsAsync** + - Downloads delta changes with metadata only + - Uses `DownloadMessageMetadataBatchAsync` instead of full MIME download + +3. **CreateNewMailPackagesAsync** + - Still downloads MIME for specific scenarios (search results, drafts) + - Uses `CreateMailCopyFromMessage` for consistency + - Not called during normal sync operations + +#### Removed Methods + +- `DownloadMailsConcurrentlyAsync` - Replaced by queue system +- `DownloadSingleMailAsync` - Replaced by queue batch processing +- Scattered `CreateMinimalMailCopyAsync` implementations + +## Synchronization Flow + +### Initial Sync (Per Folder) + +``` +1. SynchronizeFolderAsync + ├─ Check: !folder.IsInitialSyncCompleted + └─ DownloadMailsForInitialSyncAsync + ├─ QueueMailIdsForFolderAsync + │ ├─ Use Delta API with Select=["Id"] + │ ├─ Iterate all pages + │ ├─ Create MailItemQueue entries with RemoteFolderId + │ └─ Store delta token + └─ ProcessMailQueueForFolderAsync + ├─ Get queue items by folder (100 at a time) + ├─ Process in chunks of 20 + ├─ DownloadMessageMetadataBatchAsync + │ ├─ Concurrent download (10 max) + │ ├─ GetMessageByIdAsync (metadata fields only) + │ ├─ CreateMailCopyFromMessage + │ └─ CreateMailAsync (package with null MIME) + └─ Update queue status +``` + +### Delta Sync (Per Folder) + +``` +1. SynchronizeFolderAsync + ├─ Check: folder.IsInitialSyncCompleted + └─ ProcessDeltaChangesAndDownloadMailsAsync + ├─ Use Delta API with existing token + ├─ Collect new mail IDs + ├─ DownloadMessageMetadataBatchAsync + │ ├─ Download metadata only + │ └─ Create MailCopy entries + └─ Update delta token +``` + +### On-Demand MIME Download + +``` +User Reads Mail +└─ DownloadMissingMimeMessageAsync + ├─ Download full MIME via /messages/{id}/$value + └─ SaveMimeFileAsync +``` + +## Benefits + +1. **Reduced Bandwidth**: Only metadata downloaded during sync (no 50+ MB MIME files) +2. **Faster Sync**: Parallel processing with controlled concurrency +3. **Resilient**: Queue system handles failures gracefully with retry logic +4. **Consistent**: Centralized `CreateMailCopyFromMessage` method +5. **Scalable**: Per-folder processing allows independent folder syncs + +## Code Consolidation + +### Before (Scattered Approach) + +```csharp +// Multiple places creating MailCopy +var mailCopy = message.AsMailCopy(); +mailCopy.FolderId = folder.Id; +// ... repeat in 3+ locations + +// Mixed MIME downloading +var package = await CreateNewMailPackagesAsync(...); // Downloads MIME +var minimal = await CreateMinimalMailCopyAsync(...); // No MIME +``` + +### After (Centralized Approach) + +```csharp +// Single method for all scenarios +var mailCopy = CreateMailCopyFromMessage(message, folder); + +// Clear separation +// Sync: metadata only +var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId); + +// On-demand: full MIME +await DownloadMissingMimeMessageAsync(mailCopy, ...); +``` + +## Graph API Fields Used + +Only essential fields are requested during sync: + +```csharp +private readonly string[] outlookMessageSelectParameters = +[ + "InferenceClassification", + "Flag", + "Importance", + "IsRead", + "IsDraft", + "ReceivedDateTime", + "HasAttachments", + "BodyPreview", + "Id", + "ConversationId", + "From", + "Subject", + "ParentFolderId", + "InternetMessageId", +]; +``` + +**NOT downloaded during sync**: +- Body content (HTML/Text) +- Raw MIME message +- Attachment content +- Extended properties + +## Migration Path for IMAP (Future) + +The base `WinoSynchronizer` class has been updated with: + +```csharp +protected virtual Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default); +protected virtual Task> DownloadMailsFromQueueAsync(MailItemFolder folder, int batchSize, CancellationToken cancellationToken = default); +protected virtual Task CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default); +``` + +IMAP can override these methods to implement similar queue-based sync: +- TODO: Add folder-based queue support to IMAP +- TODO: Implement metadata-only header parsing +- TODO: Centralize IMAP MailCopy creation + +## Testing Checklist + +- [x] Initial sync queues all mail IDs per folder +- [x] Queue processing downloads metadata only +- [x] Delta sync uses metadata-only approach +- [x] Failed queue items retry correctly +- [x] Concurrent download respects semaphore limits +- [x] Delta token stored and used correctly per folder +- [ ] Search results still download MIME when needed +- [ ] Draft handling works with MIME headers +- [ ] On-demand MIME download functions correctly +- [ ] Large folders (1000+ messages) sync efficiently +- [ ] Network interruption recovery + +## Performance Expectations + +### Before (With MIME): +- 100 messages ≈ 100-500 MB download +- Sync time: 5-10 minutes +- API calls: 100+ individual message downloads + +### After (Metadata Only): +- 100 messages ≈ 1-5 MB download (metadata) +- Sync time: 30-60 seconds +- API calls: Batched requests (10-20 concurrent) +- MIME downloaded only when user reads (lazy loading) + +## Notes + +- `CreateNewMailPackagesAsync` is now marked as **only for special cases** +- `DefaultChangeProcessor` no longer needed for basic operations +- All synchronizers can benefit from this pattern (Gmail, IMAP, Outlook) +- The `InitialSyncMimeDownloadCount` property is now obsolete diff --git a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs index 29a200cf..d8d81f7d 100644 --- a/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs +++ b/Wino.Core.Domain/Entities/Calendar/AccountCalendar.cs @@ -4,6 +4,7 @@ using Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Entities.Calendar; +[Preserve] public class AccountCalendar : IAccountCalendar { [PrimaryKey] diff --git a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs index 47aa7968..0ea9974e 100644 --- a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs +++ b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs @@ -40,7 +40,7 @@ public class MailItemFolder : IMailItemFolder /// Whether initial synchronization of mail ids is completed for this folder. /// Used to determine if we should queue all mail ids first or start downloading from queue. /// - public bool IsInitialSyncCompleted { get; set; } + public InitialSynchronizationStatus FolderStatus { get; set; } // For GMail Labels public string TextColorHex { get; set; } diff --git a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs index 0123e97f..25a73ceb 100644 --- a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs +++ b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs @@ -9,6 +9,7 @@ public class MailItemQueue public Guid Id { get; set; } public Guid AccountId { get; set; } public string RemoteServerId { get; set; } + public string RemoteFolderId { get; set; } // For Outlook per-folder sync public bool IsProcessed { get; set; } public int FailedCount { get; set; } public DateTime CreatedAt { get; set; } diff --git a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs index b3a917bd..b978978f 100644 --- a/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs +++ b/Wino.Core.Domain/Interfaces/IAccountMenuItem.cs @@ -7,7 +7,27 @@ namespace Wino.Core.Domain.Interfaces; public interface IAccountMenuItem : IMenuItem { bool IsEnabled { get; set; } - double SynchronizationProgress { get; set; } + + /// + /// Calculated synchronization progress percentage (0-100). -1 for indeterminate. + /// + double SynchronizationProgress { get; } + + /// + /// Total items to sync. 0 for indeterminate progress. + /// + int TotalItemsToSync { get; set; } + + /// + /// Remaining items to sync. + /// + int RemainingItemsToSync { get; set; } + + /// + /// Current synchronization status message. + /// + string SynchronizationStatus { get; set; } + int UnreadItemCount { get; set; } IEnumerable HoldingAccounts { get; } void UpdateAccount(MailAccount account); diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index d6824e80..5a81fb3e 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -166,5 +166,7 @@ public interface IMailService Task AddMailItemQueueItemsAsync(IEnumerable queueItems); Task GetMailItemQueueCountAsync(Guid accountId); Task> GetMailItemQueueAsync(Guid accountId, int take); + Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take); + Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId); Task UpdateMailItemQueueAsync(IEnumerable queueItems); } diff --git a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs index 7b1e881e..46a480f1 100644 --- a/Wino.Core.Domain/MenuItems/AccountMenuItem.cs +++ b/Wino.Core.Domain/MenuItems/AccountMenuItem.cs @@ -14,18 +14,57 @@ public partial class AccountMenuItem : MenuItemBase + /// Total items to sync. 0 means indeterminate progress. + /// [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))] - private double synchronizationProgress; + [NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible), nameof(SynchronizationProgress), nameof(IsProgressIndeterminate))] + public partial int TotalItemsToSync { get; set; } + + /// + /// Remaining items to sync. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress))] + public partial int RemainingItemsToSync { get; set; } + + /// + /// Current synchronization status message. + /// + [ObservableProperty] + public partial string SynchronizationStatus { get; set; } = string.Empty; [ObservableProperty] private bool _isEnabled = true; public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None; - public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100; - // We can't determine the progress for gmail synchronization since it is based on history changes. - public bool IsProgressIndeterminate => Parameter?.ProviderType == MailProviderType.Gmail; + /// + /// Calculates synchronization progress percentage (0-100). + /// Returns -1 for indeterminate progress when TotalItemsToSync is 0. + /// + public double SynchronizationProgress + { + get + { + if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) + return -1; // Indeterminate + + return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; + } + } + + /// + /// Whether synchronization progress should be visible. + /// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0). + /// + public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0; + + /// + /// Whether progress should be indeterminate (when total is 0 but there's still synchronization happening). + /// + public bool IsProgressIndeterminate => TotalItemsToSync == 0 && RemainingItemsToSync == 0 && IsSynchronizationProgressVisible; + public Guid AccountId => Parameter.Id; private AccountAttentionReason attentionReason; diff --git a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs index 5cbf7c97..b64e1b2a 100644 --- a/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs +++ b/Wino.Core.Domain/MenuItems/MergedAccountMenuItem.cs @@ -16,8 +16,39 @@ public partial class MergedAccountMenuItem : MenuItemBase + /// Total items to sync across all merged accounts. + /// [ObservableProperty] - private double synchronizationProgress; + [NotifyPropertyChangedFor(nameof(SynchronizationProgress))] + public partial int TotalItemsToSync { get; set; } + + /// + /// Remaining items to sync across all merged accounts. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SynchronizationProgress))] + public partial int RemainingItemsToSync { get; set; } + + /// + /// Current synchronization status message. + /// + [ObservableProperty] + public partial string SynchronizationStatus { get; set; } = string.Empty; + + /// + /// Calculated synchronization progress for merged accounts. + /// + public double SynchronizationProgress + { + get + { + if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) + return -1; // Indeterminate + + return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; + } + } [ObservableProperty] private string mergedAccountName; @@ -35,6 +66,20 @@ public partial class MergedAccountMenuItem : MenuItemBase().Sum(a => a.UnreadItemCount); } + + /// + /// Aggregates synchronization progress from all child account menu items. + /// + public void RefreshSynchronizationProgress() + { + var accountMenuItems = SubMenuItems.OfType().ToList(); + + TotalItemsToSync = accountMenuItems.Sum(a => a.TotalItemsToSync); + RemainingItemsToSync = accountMenuItems.Sum(a => a.RemainingItemsToSync); + + // Use first non-empty status message + SynchronizationStatus = accountMenuItems.FirstOrDefault(a => !string.IsNullOrEmpty(a.SynchronizationStatus))?.SynchronizationStatus ?? string.Empty; + } public void UpdateAccount(MailAccount account) { diff --git a/Wino.Core.WinUI/Services/ThumbnailService.cs b/Wino.Core.WinUI/Services/ThumbnailService.cs index c6020039..f7f51faa 100644 --- a/Wino.Core.WinUI/Services/ThumbnailService.cs +++ b/Wino.Core.WinUI/Services/ThumbnailService.cs @@ -7,7 +7,6 @@ using System.Net.Mail; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using Gravatar; -using Windows.Networking.Connectivity; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Messaging.UI; @@ -92,10 +91,10 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS // No network available, skip fetching Gravatar // Do not cache it, since network can be available later - bool isInternetAvailable = GetIsInternetAvailable(); + //bool isInternetAvailable = GetIsInternetAvailable(); - if (!isInternetAvailable) - return default; + //if (!isInternetAvailable) + // return default; if (!_requests.TryGetValue(email, out var request)) { @@ -112,11 +111,11 @@ public class ThumbnailService(IPreferencesService preferencesService, IDatabaseS return default; - static bool GetIsInternetAvailable() - { - var connection = NetworkInformation.GetInternetConnectionProfile(); - return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess; - } + //static bool GetIsInternetAvailable() + //{ + // var connection = NetworkInformation.GetInternetConnectionProfile(); + // return connection != null && connection.GetNetworkConnectivityLevel() == NetworkConnectivityLevel.InternetAccess; + //} } private async Task RequestNewThumbnail(string email) diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index a74504f0..ae4cbc1b 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -69,6 +69,8 @@ public interface IDefaultChangeProcessor Task AddMailItemQueueItemsAsync(IEnumerable queueItems); Task GetMailItemQueueCountAsync(Guid accountId); Task> GetMailItemQueueAsync(Guid accountId, int take); + Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take); + Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId); Task UpdateMailItemQueueAsync(IEnumerable queueItems); } @@ -225,6 +227,12 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task> GetMailItemQueueAsync(Guid accountId, int take) => MailService.GetMailItemQueueAsync(accountId, take); + public Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take) + => MailService.GetMailItemQueueByFolderAsync(accountId, remoteFolderId, take); + + public Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId) + => MailService.GetMailItemQueueCountByFolderAsync(accountId, remoteFolderId); + public Task UpdateMailItemQueueAsync(IEnumerable queueItems) => MailService.UpdateMailItemQueueAsync(queueItems); diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 7a434232..9532c29b 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -39,7 +39,10 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, => Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId); public Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted) - => Connection.ExecuteAsync("UPDATE MailItemFolder SET IsInitialSyncCompleted = ? WHERE Id = ?", isCompleted, folderId); + { + var status = isCompleted ? InitialSynchronizationStatus.Completed : InitialSynchronizationStatus.None; + return Connection.ExecuteAsync("UPDATE MailItemFolder SET FolderStatus = ? WHERE Id = ?", status, folderId); + } public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount) { diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 267ef01a..500f14f9 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -13,12 +14,14 @@ using Wino.Messaging.UI; namespace Wino.Core.Synchronizers; -public abstract class BaseSynchronizer : IBaseSynchronizer +public abstract partial class BaseSynchronizer : ObservableObject, IBaseSynchronizer { protected SemaphoreSlim synchronizationSemaphore = new(1); protected CancellationToken activeSynchronizationCancellationToken; protected List changeRequestQueue = []; + protected readonly IMessenger Messenger; + public MailAccount Account { get; } private AccountSynchronizerState state; @@ -29,13 +32,87 @@ public abstract class BaseSynchronizer : IBaseSynchronizer { state = value; - WeakReferenceMessenger.Default.Send(new AccountSynchronizerStateChanged(Account.Id, value)); + // Send state changed message with current progress information + Messenger.Send(new AccountSynchronizerStateChanged( + Account.Id, + value, + TotalItemsToSync, + RemainingItemsToSync, + SynchronizationStatus)); } } - protected BaseSynchronizer(MailAccount account) + /// + /// Current synchronization status message. + /// + [ObservableProperty] + public partial string SynchronizationStatus { get; set; } = string.Empty; + + /// + /// Total items to download/sync in current operation. + /// 0 means no active download or indeterminate progress. + /// + [ObservableProperty] + public partial int TotalItemsToSync { get; set; } + + /// + /// Remaining items to download/sync in current operation. + /// + [ObservableProperty] + public partial int RemainingItemsToSync { get; set; } + + /// + /// Calculated progress percentage (0-100) based on TotalItemsToSync and RemainingItemsToSync. + /// Returns -1 for indeterminate progress (when both are 0). + /// + public double SynchronizationProgress + { + get + { + if (TotalItemsToSync == 0 || RemainingItemsToSync == 0) + return -1; // Indeterminate + + return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100; + } + } + + protected BaseSynchronizer(MailAccount account, IMessenger messenger) { Account = account; + Messenger = messenger ?? WeakReferenceMessenger.Default; + } + + /// + /// Resets synchronization progress to default state. + /// + protected void ResetSyncProgress() + { + TotalItemsToSync = 0; + RemainingItemsToSync = 0; + SynchronizationStatus = string.Empty; + OnPropertyChanged(nameof(SynchronizationProgress)); + } + + /// + /// Updates synchronization progress with current item counts. + /// + /// Total items to sync + /// Remaining items to sync + /// Optional status message + protected void UpdateSyncProgress(int total, int remaining, string status = "") + { + TotalItemsToSync = total; + RemainingItemsToSync = remaining; + SynchronizationStatus = status; + OnPropertyChanged(nameof(SynchronizationProgress)); + + // Send progress update message + Messenger.Send(new AccountSynchronizerStateChanged( + Account.Id, + State, + TotalItemsToSync, + RemainingItemsToSync, + SynchronizationStatus)); } /// diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index f2a921e6..7356c994 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -88,7 +88,7 @@ public class GmailSynchronizer : WinoSynchronizer +/// Outlook synchronizer implementation with queue-based metadata-only synchronization. +/// +/// SYNCHRONIZATION STRATEGY: +/// - Uses per-folder queue system (unlike Gmail's per-account queue) +/// - During sync (initial/delta), only message metadata is downloaded (no MIME content) +/// - Messages are queued by folder using MailItemQueue with RemoteFolderId +/// - MailCopy objects are created from Graph API metadata fields only +/// - MIME files are downloaded on-demand when user explicitly reads a message +/// - This dramatically reduces bandwidth usage and sync time +/// +/// Key implementation details: +/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API +/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue +/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches +/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only) +/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested +/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME) +/// public class OutlookSynchronizer : WinoSynchronizer { public override uint BatchModificationSize => 20; @@ -93,7 +113,7 @@ public class OutlookSynchronizer : WinoSynchronizer a.FolderName)), synchronizationFolders.Count)); - for (int i = 0; i < synchronizationFolders.Count; i++) + var totalFolders = synchronizationFolders.Count; + + for (int i = 0; i < totalFolders; i++) { var folder = synchronizationFolders[i]; - var progress = (int)Math.Round((double)(i + 1) / synchronizationFolders.Count * 100); - - PublishSynchronizationProgress(progress); + + // Update progress based on folder completion + UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}..."); var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false); downloadedMessageIds.AddRange(folderDownloadedMessageIds); @@ -188,7 +211,8 @@ public class OutlookSynchronizer : WinoSynchronizer - /// Downloads mails for initial synchronization using Delta API and direct download with concurrency control. + /// Downloads mails for initial synchronization using Delta API and queue-based system. + /// First, queues all mail IDs, then downloads metadata in batches. /// private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken) { _logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName); - var mailIds = new List(); - try { - // Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs - DeltaGetResponse messageCollectionPage = null; + // Step 1: Queue all mail IDs using Delta API + await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false); - if (string.IsNullOrEmpty(folder.DeltaToken)) - { - messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) => - { - config.QueryParameters.Select = ["Id"]; // Only get the message Ids - config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc - config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder; - }, cancellationToken).ConfigureAwait(false); - } - else - { - var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) => - { - config.QueryParameters.Select = ["Id"]; // Only get the message Ids - config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc - }); - - requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); - requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken); - - messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken); - } - - // Use PageIterator for iterating through the messages - var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, (message) => - { - if (!IsResourceDeleted(message.AdditionalData)) - { - mailIds.Add(message.Id); - } - - // Iterator must continue all the time to recieve delta token at the end. - return true; - }); - - await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false); - - // Extract delta token from the iterator's delta link - string deltaToken = null; - if (!string.IsNullOrEmpty(messageIterator.Deltalink)) - { - deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink); - } - - // Download mails concurrently with semaphore control - if (mailIds.Any()) - { - var mimeDownloadCount = Math.Min(mailIds.Count, InitialSyncMimeDownloadCount); - _logger.Information("Starting concurrent download of {Count} mails for folder {FolderName} (first {MimeCount} with MIME messages)", - mailIds.Count, folder.FolderName, mimeDownloadCount); - await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); - } - else - { - _logger.Information("No mail ids found to download for folder {FolderName}", folder.FolderName); - } - - // Store the delta token for future incremental syncs - always store when available - if (!string.IsNullOrEmpty(deltaToken)) - { - await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false); - await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false); - folder.DeltaToken = deltaToken; - _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName); - } - else - { - _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName); - } + // Step 2: Process queued mail IDs in batches + await ProcessMailQueueForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); } catch (ApiException apiException) { @@ -368,7 +324,7 @@ public class OutlookSynchronizer : WinoSynchronizer - /// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10. - /// This overload is used for initial sync where MIME messages are downloaded for the first 50 messages. + /// Queues all mail IDs for a folder using Delta API. + /// Only retrieves message IDs to minimize data transfer. /// - private async Task DownloadMailsConcurrentlyAsync(List mailIds, MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken) + private async Task QueueMailIdsForFolderAsync(MailItemFolder folder, CancellationToken cancellationToken) { - await DownloadMailsConcurrentlyAsync(mailIds, folder, downloadedMessageIds, true, cancellationToken).ConfigureAwait(false); - } + _logger.Debug("Queuing mail IDs for folder {FolderName}", folder.FolderName); - /// - /// Downloads mails concurrently with semaphore control to limit concurrent downloads to 10. - /// - private async Task DownloadMailsConcurrentlyAsync(List mailIds, MailItemFolder folder, List downloadedMessageIds, bool isInitialSync, CancellationToken cancellationToken) - { - var downloadTasks = mailIds.Select(async (mailId, index) => + var mailIds = new List(); + + // Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs + DeltaGetResponse messageCollectionPage = null; + + if (string.IsNullOrEmpty(folder.DeltaToken)) { - await _concurrentDownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try + messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) => { - // Download MIME for the first 50 messages during initial sync only - bool shouldDownloadMime = isInitialSync && index < InitialSyncMimeDownloadCount; - var downloaded = await DownloadSingleMailAsync(mailId, folder, shouldDownloadMime, cancellationToken).ConfigureAwait(false); - if (downloaded != null) - { - lock (downloadedMessageIds) - { - downloadedMessageIds.Add(downloaded); - } - } - } - finally + config.QueryParameters.Select = ["Id"]; // Only get the message Ids + config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc + config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder; + }, cancellationToken).ConfigureAwait(false); + } + else + { + var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) => { - _concurrentDownloadSemaphore.Release(); + config.QueryParameters.Select = ["Id"]; // Only get the message Ids + config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc + }); + + requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); + requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken); + + messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken); + } + + // Use PageIterator to iterate through all messages and collect IDs + var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, (message) => + { + if (!IsResourceDeleted(message.AdditionalData)) + { + mailIds.Add(message.Id); } + + // Iterator must continue all the time to receive delta token at the end. + return true; }); - await Task.WhenAll(downloadTasks).ConfigureAwait(false); + await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false); + + // Extract delta token from the iterator's delta link + string deltaToken = null; + if (!string.IsNullOrEmpty(messageIterator.Deltalink)) + { + deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink); + } + + // Queue all mail IDs for processing + if (mailIds.Any()) + { + var queueEntries = mailIds.Select(id => new MailItemQueue + { + Id = Guid.CreateVersion7(), + AccountId = Account.Id, + RemoteServerId = id, + RemoteFolderId = folder.RemoteFolderId, + IsProcessed = false, + CreatedAt = DateTime.UtcNow + }); + + await _outlookChangeProcessor.AddMailItemQueueItemsAsync(queueEntries).ConfigureAwait(false); + + _logger.Information("Queued {Count} mail IDs for folder {FolderName}", mailIds.Count, folder.FolderName); + } + else + { + _logger.Information("No mail ids found to queue for folder {FolderName}", folder.FolderName); + } + + // Store the delta token for future incremental syncs - always store when available + if (!string.IsNullOrEmpty(deltaToken)) + { + await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false); + await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false); + folder.DeltaToken = deltaToken; + _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName); + } + else + { + _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName); + } } /// - /// Downloads a single mail by ID and creates it in the database. + /// Processes queued mail IDs in batches, downloading metadata only (no MIME). /// - private async Task DownloadSingleMailAsync(string mailId, MailItemFolder folder, bool downloadMime, CancellationToken cancellationToken) + private async Task ProcessMailQueueForFolderAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken) { - try + var totalInQueue = await _outlookChangeProcessor.GetMailItemQueueCountByFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false); + + if (totalInQueue == 0) { - // Check if mail already exists in database before downloading - // to avoid unnecessary API calls and reprocessing existing mails - bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(mailId, folder.Id).ConfigureAwait(false); - if (mailExists) + _logger.Information("No mails in queue for folder {FolderName}", folder.FolderName); + return; + } + + _logger.Information("Processing {Count} queued mails for folder {FolderName}", totalInQueue, folder.FolderName); + + var totalFailed = 0; + var totalProcessed = 0; + + // Set initial progress for queue processing + UpdateSyncProgress(totalInQueue, totalInQueue, $"Downloading {folder.FolderName}..."); + + // Continue until all emails in queue are processed + while (true) + { + // Get next batch of unprocessed emails from queue + var mailItemQueue = await _outlookChangeProcessor.GetMailItemQueueByFolderAsync(Account.Id, folder.RemoteFolderId, 100).ConfigureAwait(false); + + if (mailItemQueue.Count == 0) + break; // No more emails to process + + // Remove the items that should be deleted from queue first + mailItemQueue.RemoveAll(a => a.ShouldDelete()); + + var mailChunks = mailItemQueue.Chunk(20); // Process 20 at a time + + foreach (var chunk in mailChunks) { - _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", mailId, folder.FolderName); - return null; // Not a new download + cancellationToken.ThrowIfCancellationRequested(); + + // Collect message IDs from the chunk + var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList(); + + try + { + // Download all messages in this chunk concurrently + var chunkDownloadedIds = await DownloadMessageMetadataBatchAsync(messageIdsToDownload, folder, cancellationToken).ConfigureAwait(false); + + downloadedMessageIds.AddRange(chunkDownloadedIds); + + // Mark all items in chunk as processed + foreach (var queueItem in chunk) + { + queueItem.IsProcessed = true; + queueItem.ProcessedAt = DateTime.UtcNow; + totalProcessed++; + } + + // Update progress with remaining items + var remainingItems = totalInQueue - totalProcessed; + UpdateSyncProgress(totalInQueue, remainingItems, $"Downloading {folder.FolderName} ({totalProcessed}/{totalInQueue})"); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to download chunk of messages for folder {FolderName}", folder.FolderName); + + // Mark all items in chunk as failed + foreach (var queueItem in chunk) + { + queueItem.IsProcessed = false; + queueItem.ProcessedAt = null; + queueItem.FailedCount++; + totalFailed++; + } + } + + await _outlookChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false); + + // If too many failures, pause to avoid hitting rate limits + if (totalFailed > 50) + { + _logger.Warning("Too many failures ({Count}), pausing for 10 seconds", totalFailed); + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + totalFailed = 0; // Reset counter + } } - // Download the message with minimal properties - var message = await GetMessageByIdAsync(mailId, cancellationToken).ConfigureAwait(false); + _logger.Debug("Processed batch: {Processed}/{Total} for folder {FolderName}", totalProcessed, totalInQueue, folder.FolderName); + } - if (message != null) + _logger.Information("Completed processing queue for folder {FolderName}. Processed: {Count}", folder.FolderName, totalProcessed); + } + + /// + /// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content). + /// Processes up to 20 messages per batch request as per MaximumAllowedBatchRequestSize. + /// + private async Task> DownloadMessageMetadataBatchAsync(List messageIds, MailItemFolder folder, CancellationToken cancellationToken) + { + if (messageIds == null || messageIds.Count == 0) + return new List(); + + var downloadedIds = new List(); + + // Filter out messages that already exist in the database + var messagesToDownload = new List(); + foreach (var messageId in messageIds) + { + bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(messageId, folder.Id).ConfigureAwait(false); + if (!mailExists) { - if (downloadMime) - { - // Download the full message packages with MIME for the first 50 messages - var mailPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false); + messagesToDownload.Add(messageId); + } + else + { + _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping download", messageId, folder.FolderName); + } + } - if (mailPackages != null) + if (messagesToDownload.Count == 0) + { + _logger.Debug("All messages already exist in folder {FolderName}", folder.FolderName); + return downloadedIds; + } + + // Process in batches of MaximumAllowedBatchRequestSize (20) + var batches = messagesToDownload.Batch((int)MaximumAllowedBatchRequestSize); + + foreach (var batch in batches) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var batchContent = new BatchRequestContentCollection(_graphClient); + var requestIdToMessageIdMap = new Dictionary(); + + // Add all message requests to the batch + foreach (var messageId in batch) + { + var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) => { - foreach (var package in mailPackages) + config.QueryParameters.Select = outlookMessageSelectParameters; + }); + + var batchRequestId = await batchContent.AddBatchRequestStepAsync(requestInfo).ConfigureAwait(false); + requestIdToMessageIdMap[batchRequestId] = messageId; + } + + // Execute the batch request + var batchResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false); + + // Process all responses + foreach (var batchRequestId in requestIdToMessageIdMap.Keys) + { + var messageId = requestIdToMessageIdMap[batchRequestId]; + + try + { + // Deserialize the Message directly from batch response + var message = await batchResponse.GetResponseByIdAsync(batchRequestId).ConfigureAwait(false); + + if (message != null) { - if (package?.Copy != null) + // Create MailCopy from metadata only + var mailCopy = CreateMailCopyFromMessage(message, folder); + + if (mailCopy != null) { + // Create package without MIME + var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId); bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); if (isInserted) { - _logger.Debug("Downloaded MIME message {MailId} for folder {FolderName}", mailId, folder.FolderName); - return package.Copy.Id; // Successfully created with MIME + downloadedIds.Add(mailCopy.Id); + _logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName); } else { - _logger.Warning("Failed to insert mail with MIME {MailId} for folder {FolderName}", mailId, folder.FolderName); + _logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", messageId, folder.FolderName); } } } - } - else - { - _logger.Debug("Could not create MIME mail packages for {MailId} in folder {FolderName}", mailId, folder.FolderName); - } - } - else - { - // Create minimal MailCopy without downloading MIME - var mailCopy = await CreateMinimalMailCopyAsync(message, folder, cancellationToken).ConfigureAwait(false); - - if (mailCopy != null) - { - // Create a minimal package without MIME for direct sync - var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId); - bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); - - if (isInserted) + else { - return mailCopy.Id; // Successfully created + _logger.Warning("Failed to deserialize message {MailId} for folder {FolderName}", messageId, folder.FolderName); + } + } + catch (ODataError odataError) + { + // Handle OData errors from the batch response + if (odataError.ResponseStatusCode == 404) + { + _logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", messageId, folder.FolderName); } else { - _logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", mailId, folder.FolderName); + _logger.Error("OData error while downloading mail {MailId} for folder {FolderName}. Error: {Error}", messageId, folder.FolderName, odataError.Error?.Message); } } - else + catch (ServiceException serviceException) { - _logger.Debug("Could not create MailCopy for {MailId} in folder {FolderName} (might be unsupported message type)", mailId, folder.FolderName); + // Try to handle the error using the error handling factory + var errorContext = new SynchronizerErrorContext + { + Account = Account, + ErrorCode = (int?)serviceException.ResponseStatusCode, + ErrorMessage = $"Service error during batch mail download: {serviceException.Message}", + Exception = serviceException + }; + + var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); + + if (!handled) + { + _logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", messageId, folder.FolderName, serviceException.ResponseStatusCode); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Error occurred while processing message {MailId} for folder {FolderName}", messageId, folder.FolderName); } } } - else + catch (Exception ex) { - _logger.Debug("Message {MailId} is null for folder {FolderName} (filtered out)", mailId, folder.FolderName); + _logger.Error(ex, "Error occurred during batch download for folder {FolderName}", folder.FolderName); } } - catch (ServiceException serviceException) - { - // Try to handle the error using the error handling factory first - var errorContext = new SynchronizerErrorContext - { - Account = Account, - ErrorCode = (int?)serviceException.ResponseStatusCode, - ErrorMessage = $"Service error during mail download: {serviceException.Message}", - Exception = serviceException - }; - - var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); - - if (!handled) - { - // No handler could process this error, log appropriately - if (serviceException.ResponseStatusCode == 404) - { - _logger.Warning("Mail {MailId} not found on server (404) for folder {FolderName}", mailId, folder.FolderName); - } - else - { - _logger.Error(serviceException, "Unhandled service error while downloading mail {MailId} for folder {FolderName}. Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode); - } - } - else - { - _logger.Information("Service error handled successfully during mail download. Mail: {MailId}, Folder: {FolderName}, Error: {ErrorCode}", mailId, folder.FolderName, serviceException.ResponseStatusCode); - } - } - catch (Exception ex) - { - _logger.Error(ex, "Error occurred while downloading mail {MailId} for folder {FolderName}", mailId, folder.FolderName); - } - return null; + return downloadedIds; + } + + /// + /// Creates a MailCopy from an Outlook Message with metadata only (centralized method). + /// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls. + /// + private MailCopy CreateMailCopyFromMessage(Message message, MailItemFolder assignedFolder) + { + if (message == null) return null; + + var mailCopy = message.AsMailCopy(); + mailCopy.FolderId = assignedFolder.Id; + mailCopy.UniqueId = Guid.NewGuid(); + mailCopy.FileId = Guid.NewGuid(); + + return mailCopy; } private string GetDeltaTokenFromDeltaLink(string deltaLink) @@ -552,23 +691,14 @@ public class OutlookSynchronizer : WinoSynchronizer(); - await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false); + // Queue all mail IDs for the folder + await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false); } protected override Task CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { - if (message == null) return Task.FromResult(null); - - // Create MailCopy with minimal properties - no MIME download - var mailCopy = message.AsMailCopy(); - mailCopy.FolderId = assignedFolder.Id; - mailCopy.UniqueId = Guid.NewGuid(); - mailCopy.FileId = Guid.NewGuid(); - - return Task.FromResult(mailCopy); + // Use centralized method + return Task.FromResult(CreateMailCopyFromMessage(message, assignedFolder)); } private async Task GetMessageByIdAsync(string messageId, CancellationToken cancellationToken = default) @@ -622,7 +752,7 @@ public class OutlookSynchronizer : WinoSynchronizer downloadedMessageIds, CancellationToken cancellationToken = default) { - // Process delta changes and directly download new mails + // Process delta changes and download new mails with metadata only (no MIME) if (string.IsNullOrEmpty(folder.DeltaToken)) { _logger.Debug("No delta token available for folder {FolderName}. Skipping delta sync.", folder.FolderName); @@ -638,7 +768,7 @@ public class OutlookSynchronizer : WinoSynchronizer { - config.QueryParameters.Select = ["Id"]; // Only get IDs for direct download + config.QueryParameters.Select = ["Id"]; // Only get IDs config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc }); @@ -665,11 +795,12 @@ public class OutlookSynchronizer : WinoSynchronizer> CreateNewMailPackagesAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) { + // Download MIME message for specific scenarios (e.g., search results, draft handling) + // During normal sync, this method should not be called - use CreateMailCopyFromMessage instead var mimeMessage = await DownloadMimeMessageAsync(message.Id, cancellationToken).ConfigureAwait(false); - var mailCopy = message.AsMailCopy(); + var mailCopy = CreateMailCopyFromMessage(message, assignedFolder); if (message.IsDraft.GetValueOrDefault() && mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index 26eac5d9..d6aa8b93 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -32,7 +32,7 @@ public abstract class WinoSynchronizer>(); - protected WinoSynchronizer(MailAccount account) : base(account) { } + protected WinoSynchronizer(MailAccount account, IMessenger messenger) : base(account, messenger) { } /// /// How many items per single HTTP call can be modified. @@ -249,7 +249,8 @@ public abstract class WinoSynchronizer WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id)); - /// - /// Sends a message to the shell to update the synchronization progress. - /// - /// Percentage of the progress. - public void PublishSynchronizationProgress(double progress) - => WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress)); - /// /// Attempts to find out the best possible synchronization options after the batch request execution. /// diff --git a/Wino.Mail.ViewModels/AppShellViewModel.cs b/Wino.Mail.ViewModels/AppShellViewModel.cs index c88af020..39a78980 100644 --- a/Wino.Mail.ViewModels/AppShellViewModel.cs +++ b/Wino.Mail.ViewModels/AppShellViewModel.cs @@ -35,7 +35,7 @@ public partial class AppShellViewModel : MailBaseViewModel, IRecipient, IRecipient, IRecipient, - IRecipient, + IRecipient, IRecipient, IRecipient, IRecipient, @@ -992,13 +992,24 @@ public partial class AppShellViewModel : MailBaseViewModel, UpdateFolderCollection(mailItemFolder); } - public async void Receive(AccountSynchronizationProgressUpdatedMessage message) + public async void Receive(AccountSynchronizerStateChanged message) { var accountMenuItem = MenuItems.GetSpecificAccountMenuItem(message.AccountId); if (accountMenuItem == null) return; - await ExecuteUIThread(() => { accountMenuItem.SynchronizationProgress = message.Progress; }); + await ExecuteUIThread(() => + { + accountMenuItem.TotalItemsToSync = message.TotalItemsToSync; + accountMenuItem.RemainingItemsToSync = message.RemainingItemsToSync; + accountMenuItem.SynchronizationStatus = message.SynchronizationStatus; + + // If this account is part of a merged inbox, update the merged inbox progress as well + if (accountMenuItem.ParentMenuItem is MergedAccountMenuItem mergedAccountMenuItem) + { + mergedAccountMenuItem.RefreshSynchronizationProgress(); + } + }); } public async void Receive(NavigateAppPreferencesRequested message) @@ -1020,7 +1031,7 @@ public partial class AppShellViewModel : MailBaseViewModel, Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); - Messenger.Register(this); + Messenger.Register(this); Messenger.Register(this); Messenger.Register(this); } @@ -1036,7 +1047,7 @@ public partial class AppShellViewModel : MailBaseViewModel, Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); - Messenger.Unregister(this); + Messenger.Unregister(this); Messenger.Unregister(this); Messenger.Unregister(this); } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index 02f4f93a..4cad0aa3 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -26,6 +26,12 @@ public class WinoMailCollection : ObservableRecipient, IRecipient> _threadIdToItemsMap = new(); + // Cache item to group mapping for faster lookups + private readonly Dictionary> _itemToGroupMap = new(); + + // Cache uniqueId to MailItemViewModel for faster GetMailItemContainer lookups + private readonly Dictionary _uniqueIdToMailItemMap = new(); + public event EventHandler MailItemRemoved; public event EventHandler ItemSelectionChanged; @@ -87,6 +93,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { _mailItemSource.InsertItem(groupKey, listComparer, mailItem, listComparer); + + // Update item-to-group cache + var group = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); + if (group != null) + { + _itemToGroupMap[mailItem] = group; + } }); } @@ -203,6 +225,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { group.Remove(mailItem); + + // Remove from item-to-group cache + _itemToGroupMap.Remove(mailItem); if (group.Count == 0) { @@ -323,10 +348,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient FindGroupContainingItem(IMailListItem item) { + // Try cache first + if (_itemToGroupMap.TryGetValue(item, out var cachedGroup)) + { + return cachedGroup; + } + + // Fallback to search if not in cache foreach (var group in _mailItemSource) { if (group.Contains(item)) { + _itemToGroupMap[item] = group; return group; } } @@ -359,9 +392,38 @@ public class WinoMailCollection : ObservableRecipient, IRecipient(); - var processedItems = new HashSet(); + var itemsList = items as List ?? items.ToList(); + if (itemsList.Count == 0) return; + + var itemsToAdd = new List(itemsList.Count); + var processedItems = new HashSet(itemsList.Count); + var itemsToUpdate = new List<(MailItemViewModel existing, MailCopy updated)>(); + var threadingOperations = new List<(ObservableGroup group, IMailListItem item, MailCopy addedItem)>(); + + // Build a lookup for existing groups to avoid repeated searches + var groupLookup = new Dictionary>(_mailItemSource.Count * 10); + foreach (var group in _mailItemSource) + { + foreach (var item in group) + { + groupLookup[item] = group; + } + } + + // Build thread lookup from the batch items + var batchThreadLookup = new Dictionary>(); + foreach (var item in itemsList) + { + if (!string.IsNullOrEmpty(item.MailCopy.ThreadId)) + { + if (!batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var list)) + { + list = new List(); + batchThreadLookup[item.MailCopy.ThreadId] = list; + } + list.Add(item); + } + } // Process items and handle threading foreach (var item in itemsList) @@ -375,7 +437,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient !processedItems.Contains(i) && - !string.IsNullOrEmpty(i.MailCopy.ThreadId) && - i.MailCopy.ThreadId == item.MailCopy.ThreadId) - .ToList(); - - if (threadableItems.Count > 1) + if (batchThreadLookup.TryGetValue(item.MailCopy.ThreadId, out var threadableItems) && threadableItems.Count > 1) { - // Create a new thread with all matching items + // Create a new thread with all matching items - defer UI operations var threadViewModel = new ThreadMailItemViewModel(item.MailCopy.ThreadId); - - await ExecuteUIThread(() => + + // Add emails without UI thread for now + foreach (var threadItem in threadableItems) { - foreach (var threadItem in threadableItems) - { - threadViewModel.AddEmail(threadItem); - } - }); + threadViewModel.AddEmail(threadItem); + } itemsToAdd.Add(threadViewModel); @@ -435,40 +488,103 @@ public class WinoMailCollection : ObservableRecipient, IRecipient new ObservableGroup(g.Key, g)); - - await ExecuteUIThread(() => + // Execute all threading operations in a single UI thread call + if (threadingOperations.Count > 0) { - foreach (var group in groupedItems) + foreach (var (group, existingItem, addedItem) in threadingOperations) { - foreach (var item in group) - { - UpdateUniqueIdHashes(item, true); - UpdateThreadIdCache(item, true); - } + await HandleThreadingAsync(group, existingItem, addedItem); + } + } - var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(group.Key); - - if (existingGroup == null) + // Execute all updates in a single UI thread call + if (itemsToUpdate.Count > 0) + { + await ExecuteUIThread(() => + { + foreach (var (existing, updated) in itemsToUpdate) { - _mailItemSource.AddGroup(group.Key, group); + UpdateUniqueIdHashes(existing, false); + UpdateUniqueIdHashes(new MailItemViewModel(updated), true); + existing.NotifyPropertyChanges(); } - else + }); + } + + // Group items by their grouping key and add them in a single UI thread call + if (itemsToAdd.Count > 0) + { + var groupedItems = itemsToAdd + .GroupBy(GetGroupingKey) + .ToDictionary(g => g.Key, g => g.ToList()); + + await ExecuteUIThread(() => + { + foreach (var kvp in groupedItems) { - foreach (var item in group) + var groupKey = kvp.Key; + var groupItems = kvp.Value; + + // Update caches first + foreach (var item in groupItems) { - existingGroup.Add(item); + UpdateUniqueIdHashes(item, true); + UpdateThreadIdCache(item, true); + } + + var existingGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); + + if (existingGroup == null) + { + var newGroup = new ObservableGroup(groupKey, groupItems); + _mailItemSource.AddGroup(groupKey, newGroup); + + // Update item-to-group cache + foreach (var item in groupItems) + { + _itemToGroupMap[item] = newGroup; + } + } + else + { + foreach (var item in groupItems) + { + existingGroup.Add(item); + _itemToGroupMap[item] = existingGroup; + } } } - } - }); + }); + } } public MailItemContainer GetMailItemContainer(Guid uniqueMailId) { + // Try cache first for fast lookup + if (_uniqueIdToMailItemMap.TryGetValue(uniqueMailId, out var cachedMailItem)) + { + // Check if it's in a thread + if (_itemToGroupMap.TryGetValue(cachedMailItem, out var cachedGroup)) + { + return new MailItemContainer(cachedMailItem); + } + + // Check all threads for this mail item + foreach (var group in _mailItemSource) + { + foreach (var item in group) + { + if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(uniqueMailId)) + { + return new MailItemContainer(cachedMailItem, threadMailItemViewModel); + } + } + } + + return new MailItemContainer(cachedMailItem); + } + + // Fallback to full search if not in cache var groupCount = _mailItemSource.Count; for (int i = 0; i < groupCount; i++) @@ -480,10 +596,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient e.MailCopy.UniqueId == uniqueMailId); + + if (singleItemViewModel != null) + { + _uniqueIdToMailItemMap[uniqueMailId] = singleItemViewModel; + } return new MailItemContainer(singleItemViewModel, threadMailItemViewModel); } @@ -851,7 +975,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient ExecuteWithoutRaiseSelectionChangedAsync(a => a.IsSelected = false, true); public Task CollapseAllThreadsAsync() => ExecuteWithoutRaiseSelectionChangedAsync(a => { if (a is ThreadMailItemViewModel thread) thread.IsThreadExpanded = false; }, true); - private async Task ExecuteUIThread(Action action) => await CoreDispatcher?.ExecuteOnUIThread(action); + private Task ExecuteUIThread(Action action) => CoreDispatcher?.ExecuteOnUIThread(action); public void Receive(SelectedItemsChangedMessage message) => _ = NotifySelectionChangesAsync(); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 65a2ff1c..97dc067b 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -110,25 +110,32 @@ public partial class MailListPageViewModel : MailBaseViewModel, [NotifyPropertyChangedFor(nameof(IsEmpty))] [NotifyPropertyChangedFor(nameof(IsFolderEmpty))] [NotifyPropertyChangedFor(nameof(IsProgressRing))] - private bool isInitializingFolder; + [NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))] + public partial bool IsInitializingFolder { get; set; } [ObservableProperty] - private InfoBarMessageType barSeverity; + [NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))] + public partial bool FinishedLoading { get; set; } = false; + + public bool CanLoadMoreItems => !IsInitializingFolder && !IsOnlineSearchEnabled && !FinishedLoading; [ObservableProperty] - private string barMessage; + public partial InfoBarMessageType BarSeverity { get; set; } + + [ObservableProperty] + public partial string BarMessage { get; set; } [ObservableProperty] public partial double MailListLength { get; set; } = 420; [ObservableProperty] - private double maxMailListLength = 1200; + public partial double MaxMailListLength { get; set; } = 1200; [ObservableProperty] - private string barTitle; + public partial string BarTitle { get; set; } [ObservableProperty] - private bool isBarOpen; + public partial bool IsBarOpen { get; set; } /// /// Current folder that is being represented from the menu. @@ -136,11 +143,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanSynchronize))] [NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))] - private IBaseFolderMenuItem activeFolder; + public partial IBaseFolderMenuItem ActiveFolder { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanSynchronize))] - private bool isAccountSynchronizerInSynchronization; + public partial bool IsAccountSynchronizerInSynchronization { get; set; } public MailListPageViewModel(IMailDialogService dialogService, INavigationService navigationService, @@ -303,6 +310,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, public partial bool IsOnlineSearchButtonVisible { get; set; } [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadMoreItemsCommand))] public partial bool IsOnlineSearchEnabled { get; set; } [ObservableProperty] @@ -538,10 +546,10 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanLoadMoreItems))] private async Task LoadMoreItemsAsync() { - if (IsInitializingFolder || IsOnlineSearchEnabled) return; + if (IsInitializingFolder || IsOnlineSearchEnabled || FinishedLoading) return; Debug.WriteLine("Loading more..."); await ExecuteUIThread(() => { IsInitializingFolder = true; }); @@ -556,6 +564,13 @@ public partial class MailListPageViewModel : MailBaseViewModel, var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false); + if (items.Count == 0) + { + await ExecuteUIThread(() => { FinishedLoading = true; }); + + return; + } + var viewModels = PrepareMailViewModels(items); await MailCollection.AddRangeAsync(viewModels, false); @@ -613,9 +628,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, await listManipulationSemepahore.WaitAsync(); - await ExecuteUIThread(async () => + // AddAsync already handles UI threading internally, no need to wrap it + await MailCollection.AddAsync(addedMail); + + await ExecuteUIThread(() => { - await MailCollection.AddAsync(addedMail); NotifyItemFoundState(); }); } @@ -671,11 +688,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, }); } - // Remove the deleted item from the list. - await ExecuteUIThread(() => - { - _ = MailCollection.RemoveAsync(removedMail); - }); + // RemoveAsync already handles UI threading internally + await MailCollection.RemoveAsync(removedMail); if (nextItem != null) WeakReferenceMessenger.Default.Send(new SelectMailItemContainerEvent(nextItem, ScrollToItem: true)); @@ -684,10 +698,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, // There are no next item to select, but we removed the last item which was selected. // Clearing selected item will dispose rendering page. - await ExecuteUIThread(() => - { - _ = MailCollection.UnselectAllAsync(); - }); + // UnselectAllAsync already handles UI threading internally + await MailCollection.UnselectAllAsync(); } await ExecuteUIThread(() => { NotifyItemFoundState(); }); @@ -709,11 +721,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, // Otherwise the draft mail item will be duplicated on the next add execution. await listManipulationSemepahore.WaitAsync(); - await ExecuteUIThread(async () => - { - // Create the item. Draft folder navigation is already done at this point. - await MailCollection.AddAsync(draftMail); + // AddAsync already handles UI threading internally + await MailCollection.AddAsync(draftMail); + await ExecuteUIThread(() => + { // New draft is created by user. Select the item. Messenger.Send(new MailItemNavigationRequested(draftMail.UniqueId, ScrollToItem: true)); @@ -726,9 +738,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - private IEnumerable PrepareMailViewModels(IEnumerable mailItems) + private List PrepareMailViewModels(IEnumerable mailItems) { - return mailItems.Select(a => new MailItemViewModel(a)); + return mailItems.Select(a => new MailItemViewModel(a)).ToList(); } [RelayCommand] @@ -1052,7 +1064,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { IsAccountSynchronizerInSynchronization = isAnyAccountSynchronizing; }); } - public void Receive(AccountCacheResetMessage message) + public async void Receive(AccountCacheResetMessage message) { if (message.Reason == AccountCacheResetReason.ExpiredCache && ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == message.AccountId)) @@ -1061,10 +1073,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (handlingFolder == null) return; - _ = ExecuteUIThread(async () => - { - await MailCollection.ClearAsync(); + // ClearAsync already handles UI threading internally + await MailCollection.ClearAsync(); + await ExecuteUIThread(() => + { _mailDialogService.InfoBarMessage(Translator.AccountCacheReset_Title, Translator.AccountCacheReset_Message, InfoBarMessageType.Warning); }); } diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs index 7873b988..d7b7cf7c 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs @@ -13,9 +13,6 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView private const string PART_ScrollViewer = "ScrollViewer"; private ScrollViewer? internalScrollviewer; - private double lastestRaisedOffset = 0; - private int lastItemSize = 0; - [GeneratedDependencyProperty] public partial bool IsThreadListView { get; set; } @@ -47,24 +44,55 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView double progress = internalScrollviewer.VerticalOffset / internalScrollviewer.ScrollableHeight; // Trigger when scrolled past 90% of total height - if (progress >= 0.9) LoadMoreCommand?.Execute(null); + if (progress >= 0.9) + { + bool canLoadMore = LoadMoreCommand?.CanExecute(null) ?? false; + + if (canLoadMore) + { + LoadMoreCommand?.Execute(null); + } + } } protected override void PrepareContainerForItemOverride(DependencyObject element, object item) { base.PrepareContainerForItemOverride(element, item); + // Ensure the container's selection state matches the model's state + // This is crucial for virtualization scenarios where containers are recycled + + if (item is MailItemViewModel mailItemViewModel + && element is WinoMailItemViewModelListViewItem container + && container.Item != mailItemViewModel) + { + container.Item = mailItemViewModel; + container.IsSelected = mailItemViewModel.IsSelected; + } + else if (item is ThreadMailItemViewModel threadMailItemViewModel + && element is WinoThreadMailItemViewModelListViewItem threadContainer + && threadContainer.Item != threadMailItemViewModel) + { + threadContainer.Item = threadMailItemViewModel; + threadContainer.IsSelected = threadMailItemViewModel.IsSelected; + threadContainer.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded; + } + } + + protected override void ClearContainerForItemOverride(DependencyObject element, object item) + { + base.ClearContainerForItemOverride(element, item); + if (item is MailItemViewModel mailItemViewModel && element is WinoMailItemViewModelListViewItem container) { - // Ensure the container's selection state matches the model's state - // This is crucial for virtualization scenarios where containers are recycled - - container.IsSelected = mailItemViewModel.IsSelected; + container.Item = null; + container.IsSelected = false; } else if (item is ThreadMailItemViewModel threadMailItemViewModel && element is WinoThreadMailItemViewModelListViewItem threadContainer) { - threadContainer.IsSelected = threadMailItemViewModel.IsSelected; - threadContainer.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded; + threadContainer.Item = null; + threadContainer.IsSelected = false; + threadContainer.IsThreadExpanded = false; } } @@ -93,24 +121,6 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView public WinoThreadMailItemViewModelListViewItem? GetThreadMailItemContainer(ThreadMailItemViewModel threadMailItemViewModel) => ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem; - public void ToggleItemContainer(IMailListItem mailListItem) - { - DispatcherQueue.TryEnqueue(() => - { - if (mailListItem is MailItemViewModel mailItemViewModel) - { - var container = GetMailItemContainer(mailItemViewModel); - container?.IsSelected = mailItemViewModel.IsSelected; - } - else if (mailListItem is ThreadMailItemViewModel threadMailItemViewModel) - { - var container = GetThreadMailItemContainer(threadMailItemViewModel); - container?.IsSelected = threadMailItemViewModel.IsSelected; - container?.IsThreadExpanded = threadMailItemViewModel.IsThreadExpanded; - } - }); - } - public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel) { WinoMailItemViewModelListViewItem? itemContainer = null; @@ -178,6 +188,7 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView } } } + public void Cleanup() { DragItemsStarting -= ItemDragStarting; diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoMailItemViewModelListViewItem.cs b/Wino.Mail.WinUI/Controls/ListView/WinoMailItemViewModelListViewItem.cs index 7204d4ad..fef3bb25 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoMailItemViewModelListViewItem.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoMailItemViewModelListViewItem.cs @@ -13,11 +13,4 @@ public partial class WinoMailItemViewModelListViewItem : ListViewItem { DefaultStyleKey = typeof(WinoMailItemViewModelListViewItem); } - - protected override void OnContentChanged(object oldContent, object newContent) - { - base.OnContentChanged(oldContent, newContent); - - if (newContent is MailItemViewModel mailItemViewModel) Item = mailItemViewModel; - } } diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs b/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs index 9806c3c7..48ef284b 100644 --- a/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs +++ b/Wino.Mail.WinUI/Controls/ListView/WinoThreadMailItemViewModelListViewItem.cs @@ -1,6 +1,5 @@ using System.Linq; using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Wino.Controls; using Wino.Helpers; @@ -16,59 +15,12 @@ public partial class WinoThreadMailItemViewModelListViewItem : ListViewItem [GeneratedDependencyProperty] public partial ThreadMailItemViewModel? Item { get; set; } - protected override void OnContentChanged(object oldContent, object newContent) - { - base.OnContentChanged(oldContent, newContent); - - if (newContent is ThreadMailItemViewModel threadMailItemViewModel) Item = threadMailItemViewModel; - } public WinoThreadMailItemViewModelListViewItem() { DefaultStyleKey = typeof(WinoThreadMailItemViewModelListViewItem); } - partial void OnIsThreadExpandedChanged(bool newValue) - { - // 1. Reflect expansion changes to WinoExpander. - // 2. Automatically select first item on expansion, if none selected. - // 3. Unselect all items on collapse. - } - - private static void OnIsThreadExpandedChanged(DependencyObject sender, DependencyPropertyChangedEventArgs dp) - { - // 1. Reflect expansion changes to WinoExpander. - // 2. Automatically select first item on expansion, if none selected. - // 3. Unselect all items on collapse. - - //var control = sender as WinoThreadMailItemViewModelListViewItem; - - //var innerControl = control?.GetWinoListViewControl(); - //var expander = control?.GetExpander(); - - //if (innerControl == null || control == null || expander == null) return; - - - //// 2 - //if (control.IsThreadExpanded && innerControl.SelectedItems.Count == 0 && innerControl.Items.Count > 0) - //{ - // innerControl.SelectedItems.Clear(); - - // // Make item selected, container might not be realized yet, so set on the model. - // // It'll appear selected when container is realized. - - // var firstItem = innerControl.Items.FirstOrDefault() as MailItemViewModel; - - // firstItem?.IsSelected = true; - //} - - //// 1 - //expander.IsExpanded = control.IsThreadExpanded; - - //// 3 - //if (!control.IsSelected) innerControl?.SelectedItems.Clear(); - } - public WinoListView? GetWinoListViewControl() { var expander = GetExpander(); diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index 9c49b925..9c09a49e 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -20,7 +20,7 @@ + Version="0.0.7.0" /> diff --git a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs index d239dd93..5b6869ae 100644 --- a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs @@ -154,17 +154,20 @@ public sealed partial class MailListPage : MailListPageAbstract, private void WinoListViewChoosingItemContainer(ListViewBase sender, ChoosingItemContainerEventArgs args) { - if (args.Item is ThreadMailItemViewModel) + if (args.Item is ThreadMailItemViewModel && args.ItemContainer is not WinoThreadMailItemViewModelListViewItem) { - args.ItemContainer = new WinoThreadMailItemViewModelListViewItem(); + args.ItemContainer = new WinoThreadMailItemViewModelListViewItem() + { + Item = args.Item as ThreadMailItemViewModel + }; } - else if (args.Item is MailItemViewModel) + else if (args.Item is MailItemViewModel && args.ItemContainer is not WinoMailItemViewModelListViewItem) { - args.ItemContainer = new WinoMailItemViewModelListViewItem(); + args.ItemContainer = new WinoMailItemViewModelListViewItem() + { + Item = args.Item as MailItemViewModel + }; } - - // Handle the preparation in PrepareContainerForItemOverride - args.IsContainerPrepared = false; } private async void MailItemContextRequested(UIElement sender, ContextRequestedEventArgs args) diff --git a/Wino.Messages/UI/AccountSynchronizerStateChanged.cs b/Wino.Messages/UI/AccountSynchronizerStateChanged.cs index 8aa1945b..3e215906 100644 --- a/Wino.Messages/UI/AccountSynchronizerStateChanged.cs +++ b/Wino.Messages/UI/AccountSynchronizerStateChanged.cs @@ -6,6 +6,14 @@ namespace Wino.Messaging.UI; /// /// Emitted when synchronizer state is updated. /// -/// Account Synchronizer -/// New state. -public record AccountSynchronizerStateChanged(Guid AccountId, AccountSynchronizerState NewState) : UIMessageBase; +/// Account id +/// New synchronizer state +/// Total items to sync (0 for indeterminate) +/// Remaining items to sync +/// Current synchronization status message +public record AccountSynchronizerStateChanged( + Guid AccountId, + AccountSynchronizerState NewState, + int TotalItemsToSync = 0, + int RemainingItemsToSync = 0, + string SynchronizationStatus = "") : UIMessageBase; diff --git a/Wino.Server/ServerContext.cs b/Wino.Server/ServerContext.cs index 01d1cd32..4219324a 100644 --- a/Wino.Server/ServerContext.cs +++ b/Wino.Server/ServerContext.cs @@ -40,7 +40,6 @@ public class ServerContext : IRecipient, IRecipient, IRecipient, - IRecipient, IRecipient, IRecipient, IRecipient, @@ -154,8 +153,6 @@ public class ServerContext : public async void Receive(AccountSynchronizerStateChanged message) => await SendMessageAsync(MessageType.UIMessage, message); - public async void Receive(AccountSynchronizationProgressUpdatedMessage message) => await SendMessageAsync(MessageType.UIMessage, message); - public async void Receive(AccountFolderConfigurationUpdated message) => await SendMessageAsync(MessageType.UIMessage, message); public async void Receive(CopyAuthURLRequested message) => await SendMessageAsync(MessageType.UIMessage, message); diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index b4d75683..fa2cea77 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -797,6 +797,9 @@ public class MailService : BaseDatabaseService, IMailService public Task GetMailItemQueueCountAsync(Guid accountId) => Connection.Table().Where(a => a.AccountId == accountId).CountAsync(); + public Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId) + => Connection.Table().Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId).CountAsync(); + public Task UpdateMailItemQueueAsync(IEnumerable queueItems) { if (queueItems == null || !queueItems.Any()) @@ -824,6 +827,16 @@ public class MailService : BaseDatabaseService, IMailService .ToListAsync(); } + public Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take) + { + // For Outlook per-folder sync + return Connection.Table() + .Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId && !a.IsProcessed) + .OrderBy(a => a.CreatedAt) + .Take(take) + .ToListAsync(); + } + #endregion private async Task CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)