some changes for progress

This commit is contained in:
Burak Kaan Köse
2025-10-31 01:41:51 +01:00
parent 4bf8f8b3d3
commit 3cc1d10b87
8 changed files with 170 additions and 53 deletions
@@ -20,14 +20,14 @@ public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem
/// Total items to sync across all merged accounts. /// Total items to sync across all merged accounts.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(SynchronizationProgress))] [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))]
public partial int TotalItemsToSync { get; set; } public partial int TotalItemsToSync { get; set; }
/// <summary> /// <summary>
/// Remaining items to sync across all merged accounts. /// Remaining items to sync across all merged accounts.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(SynchronizationProgress))] [NotifyPropertyChangedFor(nameof(SynchronizationProgress), nameof(IsSynchronizationProgressVisible), nameof(IsProgressIndeterminate))]
public partial int RemainingItemsToSync { get; set; } public partial int RemainingItemsToSync { get; set; }
/// <summary> /// <summary>
@@ -50,6 +50,17 @@ public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem
} }
} }
/// <summary>
/// Whether synchronization progress should be visible.
/// Visible when there's active synchronization (TotalItemsToSync > 0 or RemainingItemsToSync > 0).
/// </summary>
public bool IsSynchronizationProgressVisible => TotalItemsToSync > 0 || RemainingItemsToSync > 0;
/// <summary>
/// Whether progress should be indeterminate.
/// </summary>
public bool IsProgressIndeterminate => TotalItemsToSync == 0 && IsSynchronizationProgressVisible;
[ObservableProperty] [ObservableProperty]
private string mergedAccountName; private string mergedAccountName;
+30 -3
View File
@@ -154,6 +154,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (shouldSynchronizeFolders) if (shouldSynchronizeFolders)
{ {
_logger.Information("Synchronizing folders for {Name}", Account.Name); _logger.Information("Synchronizing folders for {Name}", Account.Name);
UpdateSyncProgress(0, 0, "Synchronizing folders...");
try try
{ {
@@ -169,6 +170,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
} }
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name); _logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
UpdateSyncProgress(0, 0, "Folders synchronized");
} }
// There is no specific folder synchronization in Gmail. // There is no specific folder synchronization in Gmail.
@@ -186,24 +188,30 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (Account.SynchronizationStatus == InitialSynchronizationStatus.None) if (Account.SynchronizationStatus == InitialSynchronizationStatus.None)
{ {
UpdateSyncProgress(0, 0, "Fetching email IDs...");
await FetchAllEmailIdsAsync().ConfigureAwait(false); await FetchAllEmailIdsAsync().ConfigureAwait(false);
await CompleteAccountSyncStatusAsync(); await CompleteAccountSyncStatusAsync();
UpdateSyncProgress(0, 0, "Email IDs fetched");
} }
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier)) if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
{ {
UpdateSyncProgress(0, 0, "Synchronizing changes...");
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false); await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
await CompleteAccountSyncStatusAsync(); await CompleteAccountSyncStatusAsync();
UpdateSyncProgress(0, 0, "Changes synchronized");
} }
if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched) if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched)
{ {
UpdateSyncProgress(0, 0, "Processing email metadata...");
await ProcessEmailMetadataFromQueueAsync(cancellationToken); await ProcessEmailMetadataFromQueueAsync(cancellationToken);
UpdateSyncProgress(0, 0, "Email metadata processed");
} }
if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed) if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed)
{ {
UpdateSyncProgress(0, 0, "Synchronization completed");
} }
return MailSynchronizationResult.Completed(new List<MailCopy>()); return MailSynchronizationResult.Completed(new List<MailCopy>());
@@ -220,12 +228,15 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
await _gmailChangeProcessor.ClearMailItemQueueAsync(Account.Id).ConfigureAwait(false); await _gmailChangeProcessor.ClearMailItemQueueAsync(Account.Id).ConfigureAwait(false);
var totalFetched = 0; var totalFetched = 0;
string? pageToken = null; string pageToken = null;
var pageCount = 0;
do do
{ {
try try
{ {
pageCount++;
// Use maximum page size of 500 for efficiency // Use maximum page size of 500 for efficiency
var request = _gmailService.Users.Messages.List("me"); var request = _gmailService.Users.Messages.List("me");
request.MaxResults = 500; request.MaxResults = 500;
@@ -248,6 +259,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false); await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false);
totalFetched += queueEntries1.Count(); totalFetched += queueEntries1.Count();
// Update progress - we don't know total count, so show indeterminate with status
UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs (page {pageCount})");
} }
pageToken = response.NextPageToken; pageToken = response.NextPageToken;
@@ -261,8 +275,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
continue; // Retry the same page continue; // Retry the same page
} }
} while (!string.IsNullOrEmpty(pageToken)); } while (!string.IsNullOrEmpty(pageToken));
// Final update with total count
UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs total");
} }
catch (Exception ex) catch (Exception)
{ {
throw; throw;
} }
@@ -327,6 +344,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
// Get total count for progress tracking // Get total count for progress tracking
var totalInQueue = await _gmailChangeProcessor.GetMailItemQueueCountAsync(Account.Id).ConfigureAwait(false); var totalInQueue = await _gmailChangeProcessor.GetMailItemQueueCountAsync(Account.Id).ConfigureAwait(false);
var processedCount = 0;
try try
{ {
@@ -365,6 +383,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
queueItem.IsProcessed = true; queueItem.IsProcessed = true;
queueItem.ProcessedAt = DateTime.UtcNow; queueItem.ProcessedAt = DateTime.UtcNow;
processedCount++;
} }
} }
catch (Exception) catch (Exception)
@@ -376,11 +395,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
queueItem.ProcessedAt = null; queueItem.ProcessedAt = null;
queueItem.FailedCount++; queueItem.FailedCount++;
totalFailed++; totalFailed++;
processedCount++; // Count failed items as processed for progress
} }
} }
await _gmailChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false); await _gmailChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
// Update progress based on processed items
if (totalInQueue > 0)
{
var remainingItems = totalInQueue - processedCount;
UpdateSyncProgress(totalInQueue, remainingItems, $"Processing emails: {processedCount}/{totalInQueue}");
}
// If too many failures, pause to avoid hitting rate limits // If too many failures, pause to avoid hitting rate limits
if (totalFailed > 85) await Task.Delay(TimeSpan.FromSeconds(10)); if (totalFailed > 85) await Task.Delay(TimeSpan.FromSeconds(10));
} }
@@ -514,9 +514,10 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Group items by their grouping key and add them in a single UI thread call // Group items by their grouping key and add them in a single UI thread call
if (itemsToAdd.Count > 0) if (itemsToAdd.Count > 0)
{ {
var groupedItems = itemsToAdd // Pre-compute grouping on background thread to reduce UI thread work
var groupedItems = await Task.Run(() => itemsToAdd
.GroupBy(GetGroupingKey) .GroupBy(GetGroupingKey)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList())).ConfigureAwait(false);
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
+32 -5
View File
@@ -571,7 +571,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
return; return;
} }
var viewModels = PrepareMailViewModels(items); var viewModels = await PrepareMailViewModelsAsync(items).ConfigureAwait(false);
await MailCollection.AddRangeAsync(viewModels, false); await MailCollection.AddRangeAsync(viewModels, false);
await ExecuteUIThread(() => { IsInitializingFolder = false; }); await ExecuteUIThread(() => { IsInitializingFolder = false; });
@@ -738,9 +738,19 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
} }
private List<MailItemViewModel> PrepareMailViewModels(IEnumerable<MailCopy> mailItems) private async Task<List<MailItemViewModel>> PrepareMailViewModelsAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken = default)
{ {
return mailItems.Select(a => new MailItemViewModel(a)).ToList(); // Run ViewModel creation on background thread to avoid blocking UI
return await Task.Run(() =>
{
var viewModels = new List<MailItemViewModel>();
foreach (var mailItem in mailItems)
{
cancellationToken.ThrowIfCancellationRequested();
viewModels.Add(new MailItemViewModel(mailItem));
}
return viewModels;
}, cancellationToken).ConfigureAwait(false);
} }
[RelayCommand] [RelayCommand]
@@ -764,7 +774,11 @@ public partial class MailListPageViewModel : MailBaseViewModel,
if (ActiveFolder == null) if (ActiveFolder == null)
return; return;
await ExecuteUIThread(() => { IsInitializingFolder = true; }); await ExecuteUIThread(() => {
IsInitializingFolder = true;
// Show initial loading progress
UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, "Loading emails...");
});
// Folder is changed during initialization. // Folder is changed during initialization.
// Just cancel the existing one and wait for new initialization. // Just cancel the existing one and wait for new initialization.
@@ -855,11 +869,21 @@ public partial class MailListPageViewModel : MailBaseViewModel,
if (!listManipulationCancellationTokenSource.IsCancellationRequested) if (!listManipulationCancellationTokenSource.IsCancellationRequested)
{ {
// Update progress: Creating view models
await ExecuteUIThread(() => {
UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, $"Processing {items.Count} emails...");
});
// Here they are already threaded if needed. // Here they are already threaded if needed.
// We don't need to insert them one by one. // We don't need to insert them one by one.
// Just create VMs and do bulk insert. // Just create VMs and do bulk insert.
var viewModels = PrepareMailViewModels(items); var viewModels = await PrepareMailViewModelsAsync(items, cancellationToken).ConfigureAwait(false);
// Update progress: Adding to collection
await ExecuteUIThread(() => {
UpdateBarMessage(InfoBarMessageType.Information, ActiveFolder.FolderName, "Finalizing...");
});
await MailCollection.AddRangeAsync(viewModels, clearIdCache: true); await MailCollection.AddRangeAsync(viewModels, clearIdCache: true);
@@ -895,6 +919,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
OnPropertyChanged(nameof(CanSynchronize)); OnPropertyChanged(nameof(CanSynchronize));
NotifyItemFoundState(); NotifyItemFoundState();
// Clear the loading message after completion
IsBarOpen = false;
}); });
} }
} }
+50 -14
View File
@@ -76,12 +76,25 @@
MaxLines="1" MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}" Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Parameter.Address, Mode=OneWay}" Text="{x:Bind Parameter.Address, Mode=OneWay}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis"
Visibility="{x:Bind IsSynchronizationProgressVisible, Converter={StaticResource ReverseBooleanToVisibilityConverter}, Mode=OneWay}" />
<TextBlock
x:Name="SyncStatusText"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind SynchronizationStatus, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
</StackPanel> </StackPanel>
<PathIcon <PathIcon
x:Name="AttentionIcon" x:Name="AttentionIcon"
Grid.Column="2" Grid.Column="1"
Width="16"
Height="16"
Margin="8,0"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
x:Load="{x:Bind IsAttentionRequired, Mode=OneWay}" x:Load="{x:Bind IsAttentionRequired, Mode=OneWay}"
@@ -90,18 +103,15 @@
<muxc:ProgressRing <muxc:ProgressRing
x:Name="SynchronizationProgressBar" x:Name="SynchronizationProgressBar"
Grid.ColumnSpan="3" Grid.Column="2"
Width="10" Width="16"
Height="10" Height="16"
HorizontalAlignment="Right" Margin="8,0"
HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Background="{ThemeResource AppBarItemBackgroundThemeBrush}"
Foreground="{ThemeResource AppBarItemForegroundThemeBrush}"
IsActive="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" IsActive="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}"
IsIndeterminate="{x:Bind IsProgressIndeterminate}" IsIndeterminate="{x:Bind IsProgressIndeterminate, Mode=OneWay}"
Maximum="100" Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}"
Value="{x:Bind SynchronizationProgress, Mode=OneWay}" />
</Grid> </Grid>
</controls:AccountNavigationItem> </controls:AccountNavigationItem>
</DataTemplate> </DataTemplate>
@@ -251,6 +261,10 @@
</coreControls:WinoNavigationViewItem.Icon> </coreControls:WinoNavigationViewItem.Icon>
<Grid Height="50"> <Grid Height="50">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center" Spacing="0"> <StackPanel VerticalAlignment="Center" Spacing="0">
<TextBlock <TextBlock
x:Name="AccountNameTextblock" x:Name="AccountNameTextblock"
@@ -262,12 +276,34 @@
<TextBlock <TextBlock
FontSize="12" FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1" MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}" Style="{StaticResource CaptionTextBlockStyle}"
TextTrimming="CharacterEllipsis"> Text="{x:Bind SynchronizationStatus, Mode=OneWay}"
<Run Text="{x:Bind MergedAccountCount}" /><Run Text="{x:Bind domain:Translator.MenuMergedAccountItemAccountsSuffix}" /> TextTrimming="CharacterEllipsis"
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
<TextBlock
FontSize="12"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
TextTrimming="CharacterEllipsis"
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(IsSynchronizationProgressVisible), Mode=OneWay}">
<Run Text="{x:Bind MergedAccountCount, Mode=OneWay}" /><Run Text="{x:Bind domain:Translator.MenuMergedAccountItemAccountsSuffix}" />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<muxc:ProgressRing
x:Name="SynchronizationProgressBar"
Grid.Column="1"
Width="16"
Height="16"
Margin="8,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsActive="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}"
IsIndeterminate="{x:Bind IsProgressIndeterminate, Mode=OneWay}"
Visibility="{x:Bind IsSynchronizationProgressVisible, Mode=OneWay}" />
</Grid> </Grid>
</controls:AccountNavigationItem> </controls:AccountNavigationItem>
</DataTemplate> </DataTemplate>
@@ -95,7 +95,7 @@
</DataTemplate> </DataTemplate>
</Page.Resources> </Page.Resources>
<Grid> <Grid MaxWidth="700">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
+4 -5
View File
@@ -1,7 +1,6 @@
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using SQLite; using SQLite;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
@@ -44,6 +43,10 @@ public class DatabaseService : IDatabaseService
private async Task CreateTablesAsync() private async Task CreateTablesAsync()
{ {
//typeof(AccountCalendar),
// typeof(CalendarEventAttendee),
// typeof(CalendarItem),
// typeof(Reminder),
await Connection.CreateTablesAsync(CreateFlags.None, await Connection.CreateTablesAsync(CreateFlags.None,
typeof(MailCopy), typeof(MailCopy),
typeof(MailItemFolder), typeof(MailItemFolder),
@@ -54,10 +57,6 @@ public class DatabaseService : IDatabaseService
typeof(MergedInbox), typeof(MergedInbox),
typeof(MailAccountPreferences), typeof(MailAccountPreferences),
typeof(MailAccountAlias), typeof(MailAccountAlias),
typeof(AccountCalendar),
typeof(CalendarEventAttendee),
typeof(CalendarItem),
typeof(Reminder),
typeof(Thumbnail), typeof(Thumbnail),
typeof(KeyboardShortcut), typeof(KeyboardShortcut),
typeof(MailItemQueue) typeof(MailItemQueue)
+33 -17
View File
@@ -252,33 +252,35 @@ public class MailService : BaseDatabaseService, IMailService
return [.. mails]; return [.. mails];
} }
// Include other mails in the same threads // Include other mails in the same threads - batch process to reduce DB calls
var expandedMails = new List<MailCopy>(mails); var expandedMails = new List<MailCopy>(mails);
var processedThreadIds = new HashSet<string>(); var uniqueThreadIds = mails
.Where(m => !string.IsNullOrEmpty(m.ThreadId))
.Select(m => m.ThreadId)
.Distinct()
.ToList();
foreach (var mail in mails) if (uniqueThreadIds.Count > 0)
{ {
if (!string.IsNullOrEmpty(mail.ThreadId) && !processedThreadIds.Contains(mail.ThreadId)) // Get all thread mails in a single DB call
{
processedThreadIds.Add(mail.ThreadId);
// Get all other mails with the same ThreadId that are not already in the result
var existingMailIds = expandedMails.Select(m => m.Id).ToHashSet(); var existingMailIds = expandedMails.Select(m => m.Id).ToHashSet();
var threadMails = await GetMailsByThreadIdAsync(mail.ThreadId, existingMailIds).ConfigureAwait(false); var allThreadMails = await GetMailsByThreadIdsAsync(uniqueThreadIds, existingMailIds).ConfigureAwait(false);
if (threadMails?.Any() == true) if (allThreadMails?.Count > 0)
{ {
// Load assigned properties for the thread mails // Process thread mails in parallel to improve performance
foreach (var threadMail in threadMails) var tasks = allThreadMails.Select(async threadMail =>
{ {
await LoadAssignedPropertiesWithCacheAsync(threadMail, folderCache, accountCache, contactCache).ConfigureAwait(false); await LoadAssignedPropertiesWithCacheAsync(threadMail, folderCache, accountCache, contactCache).ConfigureAwait(false);
} return threadMail;
});
// Remove items that have no assigned account or folder var processedThreadMails = await Task.WhenAll(tasks).ConfigureAwait(false);
threadMails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
expandedMails.AddRange(threadMails); // Filter out items with no assigned account or folder
} var validThreadMails = processedThreadMails.Where(m => m.AssignedAccount != null && m.AssignedFolder != null);
expandedMails.AddRange(validThreadMails);
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -301,6 +303,20 @@ public class MailService : BaseDatabaseService, IMailService
return await Connection.QueryAsync<MailCopy>(query); return await Connection.QueryAsync<MailCopy>(query);
} }
private async Task<List<MailCopy>> GetMailsByThreadIdsAsync(List<string> threadIds, HashSet<string> excludeMailIds)
{
if (threadIds?.Count == 0)
return [];
var query = new Query("MailCopy")
.WhereIn("ThreadId", threadIds)
.WhereNotIn("Id", excludeMailIds)
.SelectRaw("MailCopy.*")
.GetRawQuery();
return await Connection.QueryAsync<MailCopy>(query).ConfigureAwait(false);
}
/// <summary> /// <summary>
/// This method should used for operations with multiple mailItems. Don't use this for single mail items. /// This method should used for operations with multiple mailItems. Don't use this for single mail items.
/// Called method should provide own instances for caches. /// Called method should provide own instances for caches.