Create sub folder, delete folder, storage settings, some ui adjustments on threads.

This commit is contained in:
Burak Kaan Köse
2026-02-07 19:47:21 +01:00
parent 2cd03d5fec
commit 5bfa61a218
30 changed files with 900 additions and 58 deletions
+2
View File
@@ -19,6 +19,8 @@ public enum FolderSynchronizerOperation
RenameFolder,
EmptyFolder,
MarkFolderRead,
DeleteFolder,
CreateSubFolder,
}
public enum CalendarSynchronizerOperation
+2 -1
View File
@@ -32,5 +32,6 @@ public enum WinoPage
CalendarSettingsPage,
CalendarAccountSettingsPage,
EventDetailsPage,
SignatureAndEncryptionPage
SignatureAndEncryptionPage,
StoragePage
}
@@ -170,4 +170,9 @@ public interface IMailService
/// <param name="folderId">Folder ID.</param>
/// <param name="count">Number of recent mails to return.</param>
Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count);
/// <summary>
/// Returns all mail copies for the account created before the given UTC date.
/// </summary>
Task<List<MailCopy>> GetMailCopiesBeforeDateAsync(Guid accountId, DateTime cutoffDateUtc);
}
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
public interface IMimeStorageService
{
Task<string> GetMimeRootPathAsync();
Task<Dictionary<Guid, long>> GetAccountsMimeStorageSizesAsync(IEnumerable<Guid> accountIds);
Task DeleteAccountMimeStorageAsync(Guid accountId);
Task<int> DeleteAccountMimeStorageOlderThanAsync(Guid accountId, DateTime cutoffDateUtc);
}
@@ -739,6 +739,33 @@
"SettingsStartupItem_Title": "Startup Item",
"SettingsStore_Description": "Show some love ❤️",
"SettingsStore_Title": "Rate in Store",
"SettingsStorage_Title": "Storage",
"SettingsStorage_Description": "Scan and manage MIME cache stored in your local data folder.",
"SettingsStorage_ScanFolder": "Scan local data folder",
"SettingsStorage_NoLocalMimeDataFound": "No local MIME data found.",
"SettingsStorage_NoAccountsFound": "No accounts found.",
"SettingsStorage_TotalUsage": "Total local MIME usage: {0}",
"SettingsStorage_AccountUsageDescription": "{0} used in local MIME cache",
"SettingsStorage_DeleteAll_Title": "Delete all MIME content",
"SettingsStorage_DeleteAll_Description": "Delete this account's entire MIME cache folder.",
"SettingsStorage_DeleteAll_Button": "Delete all",
"SettingsStorage_DeleteAll_Confirm_Title": "Delete all MIME content",
"SettingsStorage_DeleteAll_Confirm_Message": "Delete all local MIME data for {0}?",
"SettingsStorage_DeleteAll_Success": "All MIME content was deleted.",
"SettingsStorage_DeleteOld_Title": "Delete old MIME content",
"SettingsStorage_DeleteOld_Description": "Delete MIME files based on mail creation date in local database.",
"SettingsStorage_DeleteOld_1Month": "> 1 month",
"SettingsStorage_DeleteOld_3Months": "> 3 months",
"SettingsStorage_DeleteOld_6Months": "> 6 months",
"SettingsStorage_DeleteOld_1Year": "> 1 year",
"SettingsStorage_DeleteOld_Confirm_Title": "Delete old MIME content",
"SettingsStorage_DeleteOld_Confirm_Message": "Delete local MIME data older than {0} for {1}?",
"SettingsStorage_DeleteOld_Success": "Deleted {0} MIME folder(s) older than {1}.",
"SettingsStorage_1Month": "1 month",
"SettingsStorage_3Months": "3 months",
"SettingsStorage_6Months": "6 months",
"SettingsStorage_1Year": "1 year",
"SettingsStorage_Months": "{0} months",
"SettingsTaskbarBadge_Description": "Include unread mail count in taskbar icon.",
"SettingsTaskbarBadge_Title": "Taskbar Badge",
"SettingsThreads_Description": "Organize messages into conversation threads.",
@@ -34,6 +34,7 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel
WinoPage.CalendarSettingsPage => Translator.SettingsCalendarSettings_Title,
WinoPage.SignatureAndEncryptionPage => Translator.SettingsSignatureAndEncryption_Title,
WinoPage.KeyboardShortcutsPage => Translator.Settings_KeyboardShortcuts_Title,
WinoPage.StoragePage => Translator.SettingsStorage_Title,
_ => throw new NotImplementedException()
};
@@ -102,6 +102,8 @@ public static class SynchronizationActionHelper
RenameFolderRequest => Translator.SyncAction_RenamingFolder,
EmptyFolderRequest => Translator.SyncAction_EmptyingFolder,
MarkFolderAsReadRequest => Translator.SyncAction_MarkingFolderAsRead,
DeleteFolderRequest => Translator.FolderOperation_Delete,
CreateSubFolderRequest => Translator.FolderOperation_CreateSubFolder,
_ => null
};
}
@@ -0,0 +1,11 @@
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Folder;
public record CreateSubFolderRequest(MailItemFolder Folder, string NewFolderName) : FolderRequestBase(Folder, FolderSynchronizerOperation.CreateSubFolder)
{
public override void ApplyUIChanges() { }
public override void RevertUIChanges() { }
}
@@ -0,0 +1,11 @@
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests.Folder;
public record DeleteFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, FolderSynchronizerOperation.DeleteFolder)
{
public override void ApplyUIChanges() { }
public override void RevertUIChanges() { }
}
@@ -130,6 +130,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator
await QueueRequestAsync(request, accountId);
await SendSyncActionsAddedAsync([request], accountId);
await QueueSynchronizationAsync(accountId);
if (folderRequest.Action is FolderOperation.Delete or FolderOperation.CreateSubFolder)
{
await QueueFoldersOnlySynchronizationAsync(accountId);
}
}
public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest)
@@ -203,6 +208,18 @@ public class WinoRequestDelegator : IWinoRequestDelegator
return Task.CompletedTask;
}
private Task QueueFoldersOnlySynchronizationAsync(Guid accountId)
{
var options = new MailSynchronizationOptions()
{
AccountId = accountId,
Type = MailSynchronizationType.FoldersOnly
};
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
return Task.CompletedTask;
}
private async Task SendSyncActionsAddedAsync(IEnumerable<IRequestBase> requests, Guid accountId, string accountName = null)
{
if (accountName == null)
+21 -7
View File
@@ -255,15 +255,29 @@ public class WinoRequestProcessor : IWinoRequestProcessor
change = new MarkFolderAsReadRequest(folder, unreadItems);
break;
//case FolderOperation.Delete:
// var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete.");
case FolderOperation.Delete:
var deleteQuestion = string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, folder.FolderName);
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(deleteQuestion, Translator.FolderOperation_Delete, Translator.FolderOperation_Delete);
// if (isConfirmed)
// change = new DeleteFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId);
if (shouldDelete)
{
change = new DeleteFolderRequest(folder);
}
// break;
//default:
// throw new NotImplementedException();
break;
case FolderOperation.CreateSubFolder:
var subFolderName = await _dialogService.ShowTextInputDialogAsync(
string.Empty,
Translator.FolderOperation_CreateSubFolder,
Translator.DialogMessage_RenameFolderMessage,
Translator.FolderOperation_CreateSubFolder);
if (!string.IsNullOrWhiteSpace(subFolderName))
{
change = new CreateSubFolderRequest(folder, subFolderName.Trim());
}
break;
}
return change;
+102 -41
View File
@@ -1253,40 +1253,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
try
{
MimeMessage mimeMessage = null;
// Extract MIME if we downloaded raw format
if (downloadRawMime)
{
mimeMessage = gmailMessage.GetGmailMimeMessage();
if (mimeMessage == null)
{
_logger.Warning("Failed to parse MIME for message {MessageId}", gmailMessage.Id);
}
}
// Create mail packages from metadata (or raw if downloaded)
// Create mail packages from metadata/raw.
// If Gmail response is Raw format, CreateNewMailPackagesAsync will parse MIME and
// include it in package(s) so it can be saved to disk.
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
// For Gmail, multiple packages share the same message (different labels/folders)
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
foreach (var package in packages)
{
// When downloaded with Raw format, Payload.Headers is not populated by Gmail API.
// Enrich the MailCopy fields (Subject, From, MessageId, etc.) from the parsed MIME.
if (downloadRawMime && mimeMessage != null)
EnrichMailCopyFromMime(package.Copy, mimeMessage);
// Create the mail copy with the MIME (if downloaded)
var packageWithMime = downloadRawMime && mimeMessage != null
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
: package;
await _gmailChangeProcessor.CreateMailAsync(Account.Id, packageWithMime).ConfigureAwait(false);
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
@@ -1414,6 +1390,38 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
public override List<IRequestBundle<IClientServiceRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
public override List<IRequestBundle<IClientServiceRequest>> DeleteFolder(DeleteFolderRequest request)
{
var networkCall = _gmailService.Users.Labels.Delete("me", request.Folder.RemoteFolderId);
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> CreateSubFolder(CreateSubFolderRequest request)
{
var parentLabelName = request.Folder.FolderName;
try
{
var parentLabel = _gmailService.Users.Labels.Get("me", request.Folder.RemoteFolderId).Execute();
if (!string.IsNullOrWhiteSpace(parentLabel?.Name))
{
parentLabelName = parentLabel.Name;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to resolve full parent label name for {FolderId}. Falling back to local folder name.", request.Folder.RemoteFolderId);
}
var label = new Label()
{
Name = $"{parentLabelName}/{request.NewFolderName}"
};
var networkCall = _gmailService.Users.Labels.Create(label, "me");
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
}
#endregion
#region Request Execution
@@ -1834,8 +1842,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
/// <summary>
/// Creates new mail packages for the given message.
/// AssignedFolder is null since the LabelId is parsed out of the Message.
/// NOTE: This method does NOT download MIME content during synchronization.
/// MIME is only downloaded when user explicitly reads the message.
/// If Gmail Message includes Raw payload, MIME is parsed and attached to packages.
/// </summary>
/// <param name="message">Gmail message to create package for (must have Metadata format).</param>
/// <param name="assignedFolder">Null, not used.</param>
@@ -1846,24 +1853,74 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
CancellationToken cancellationToken = default)
{
var packageList = new List<NewMailItemPackage>();
MimeMessage mimeMessage = null;
// Raw format is used in delta sync and does not populate Payload.Headers.
// Parse MIME from Raw so we can resolve draft mapping header and persist mime content.
if (!string.IsNullOrEmpty(message?.Raw))
{
try
{
mimeMessage = message.GetGmailMimeMessage();
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to parse MIME from raw Gmail message {MessageId}", message?.Id);
}
}
// Create base MailCopy from metadata only - NO MIME download
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Check for local draft mapping using X-Wino-Draft-Id header from metadata.
// If this is a Wino-created draft, the local copy was already mapped by the CreateDraft response handler
// with the correct Gmail Draft ID. We must NOT call MapLocalDraftAsync here because
// baseMailCopy.DraftId is derived from CreateMinimalMailCopyAsync (not the real Draft resource ID),
// which would overwrite the correctly mapped DraftId and break SendDraft.
if (mimeMessage != null)
{
// Raw responses don't include metadata headers. Backfill important fields from MIME.
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
}
// Check for local draft mapping using X-Wino-Draft-Id header.
// For Metadata format we read from Payload.Headers.
// For Raw format (Payload is null), we read from parsed MIME headers.
if (baseMailCopy.IsDraft)
{
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value
?? mimeMessage?.Headers?.FirstOrDefault(h => h.Field.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, out _))
{
// This message belongs to an existing local draft copy.
// Skip creating a new mail copy - the local copy was already mapped by the response handler.
return null;
if (Guid.TryParse(draftIdHeader, out Guid localDraftCopyUniqueId))
{
// This message belongs to existing local draft copy.
// Map remote ids to local copy and skip creating duplicate rows.
bool isMappingSuccessful = await _gmailChangeProcessor.MapLocalDraftAsync(
Account.Id,
localDraftCopyUniqueId,
baseMailCopy.Id,
baseMailCopy.DraftId,
baseMailCopy.ThreadId).ConfigureAwait(false);
if (isMappingSuccessful)
{
// Keep local draft MIME in sync with the fetched remote raw MIME if available.
if (mimeMessage != null)
{
var mappedDraftCopies = await _gmailChangeProcessor.GetMailCopiesAsync([baseMailCopy.Id]).ConfigureAwait(false);
if (mappedDraftCopies != null)
{
var savedFileIds = new HashSet<Guid>();
foreach (var mappedCopy in mappedDraftCopies)
{
if (mappedCopy.FileId == Guid.Empty || !savedFileIds.Add(mappedCopy.FileId))
continue;
await _gmailChangeProcessor.SaveMimeFileAsync(mappedCopy.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
}
}
return null;
}
}
}
}
@@ -1887,12 +1944,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create a new MailCopy instance for each label to avoid shared reference issues
var mailCopyForLabel = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
if (mimeMessage != null)
{
EnrichMailCopyFromMime(mailCopyForLabel, mimeMessage);
}
// Ensure all copies share the same Id and FileId
mailCopyForLabel.Id = sharedId;
mailCopyForLabel.FileId = sharedFileId;
// Pass null for MimeMessage - it will be downloaded later when user reads the mail
packageList.Add(new NewMailItemPackage(mailCopyForLabel, null, labelId));
packageList.Add(new NewMailItemPackage(mailCopyForLabel, mimeMessage, labelId));
}
}
@@ -270,6 +270,24 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}, request, request);
}
public override List<IRequestBundle<ImapRequest>> DeleteFolder(DeleteFolderRequest request)
{
return CreateSingleTaskBundle(async (client, item) =>
{
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
await folder.DeleteAsync().ConfigureAwait(false);
}, request, request);
}
public override List<IRequestBundle<ImapRequest>> CreateSubFolder(CreateSubFolderRequest request)
{
return CreateSingleTaskBundle(async (client, item) =>
{
var parentFolder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
await parentFolder.CreateAsync(request.NewFolderName, true).ConfigureAwait(false);
}, request, request);
}
#endregion
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
@@ -1507,6 +1507,23 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
public override List<IRequestBundle<RequestInformation>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
public override List<IRequestBundle<RequestInformation>> DeleteFolder(DeleteFolderRequest request)
{
var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToDeleteRequestInformation();
return [new HttpRequestBundle<RequestInformation>(networkCall, request)];
}
public override List<IRequestBundle<RequestInformation>> CreateSubFolder(CreateSubFolderRequest request)
{
var requestBody = new MailFolder
{
DisplayName = request.NewFolderName
};
var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ChildFolders.ToPostRequestInformation(requestBody);
return [new HttpRequestBundle<RequestInformation>(networkCall, request)];
}
#endregion
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<RequestInformation>> batchedRequests, CancellationToken cancellationToken = default)
@@ -203,6 +203,12 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
case FolderSynchronizerOperation.MarkFolderRead:
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
break;
case FolderSynchronizerOperation.DeleteFolder:
nativeRequests.AddRange(DeleteFolder(group.ElementAt(0) as DeleteFolderRequest));
break;
case FolderSynchronizerOperation.CreateSubFolder:
nativeRequests.AddRange(CreateSubFolder(group.ElementAt(0) as CreateSubFolderRequest));
break;
default:
break;
}
@@ -508,6 +514,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
public virtual List<IRequestBundle<TBaseRequest>> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
#endregion
@@ -0,0 +1,40 @@
using System.Windows.Input;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Extensions;
namespace Wino.Mail.ViewModels.Data;
public partial class AccountStorageItemViewModel(MailAccount account, long sizeBytes, ICommand deleteAllCommand, ICommand deleteOneMonthCommand, ICommand deleteThreeMonthsCommand, ICommand deleteSixMonthsCommand, ICommand deleteYearCommand) : ObservableObject
{
public MailAccount Account { get; } = account;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(SizeText))]
public partial long SizeBytes { get; set; } = sizeBytes;
[ObservableProperty]
public partial string SizeDescription { get; set; } = string.Empty;
[ObservableProperty]
public partial ICommand DeleteAllCommand { get; set; } = deleteAllCommand;
[ObservableProperty]
public partial ICommand DeleteOneMonthCommand { get; set; } = deleteOneMonthCommand;
[ObservableProperty]
public partial ICommand DeleteThreeMonthsCommand { get; set; } = deleteThreeMonthsCommand;
[ObservableProperty]
public partial ICommand DeleteSixMonthsCommand { get; set; } = deleteSixMonthsCommand;
[ObservableProperty]
public partial ICommand DeleteYearCommand { get; set; } = deleteYearCommand;
public string AccountName => string.IsNullOrWhiteSpace(Account.Name) ? Account.Address ?? string.Empty : Account.Name;
public string AccountAddress => Account.Address ?? string.Empty;
public string SizeText => SizeBytes.GetBytesReadable();
}
@@ -1074,6 +1074,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
case SynchronizationCompletedState.Success:
UpdateBarMessage(InfoBarMessageType.Success, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Success);
break;
case SynchronizationCompletedState.PartiallyCompleted:
UpdateBarMessage(InfoBarMessageType.Warning, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Failed);
break;
case SynchronizationCompletedState.Failed:
UpdateBarMessage(InfoBarMessageType.Error, ActiveFolder.FolderName, Translator.SynchronizationFolderReport_Failed);
break;
@@ -0,0 +1,237 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Extensions;
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels;
public partial class StoragePageViewModel(
IAccountService accountService,
IMimeStorageService mimeStorageService,
IMailDialogService dialogService) : MailBaseViewModel
{
private readonly ILogger _logger = Log.ForContext<StoragePageViewModel>();
private readonly IAccountService _accountService = accountService;
private readonly IMimeStorageService _mimeStorageService = mimeStorageService;
private readonly IMailDialogService _dialogService = dialogService;
public ObservableCollection<AccountStorageItemViewModel> AccountStorageItems { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBusy))]
public partial bool IsLoading { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBusy))]
public partial bool IsCleaning { get; set; }
[ObservableProperty]
public partial string MimeRootPath { get; set; } = string.Empty;
[ObservableProperty]
public partial string SummaryText { get; set; } = "";
public bool IsBusy => IsLoading || IsCleaning;
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{
base.OnNavigatedTo(mode, parameters);
await ExecuteUIThread(() => { SummaryText = Translator.SettingsStorage_NoLocalMimeDataFound; });
await RefreshStorageAsync();
}
partial void OnIsLoadingChanged(bool value)
{
UpdateAccountBusyState();
}
partial void OnIsCleaningChanged(bool value)
{
UpdateAccountBusyState();
}
private void UpdateAccountBusyState()
{
Dispatcher.ExecuteOnUIThread(() =>
{
foreach (var item in AccountStorageItems)
{
item.IsBusy = IsBusy;
}
});
}
[RelayCommand]
private async Task RefreshStorageAsync()
{
if (IsBusy) return;
await ExecuteUIThread(() => { IsLoading = true; });
try
{
var mimeRootPath = await _mimeStorageService.GetMimeRootPathAsync().ConfigureAwait(false);
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var sizeMap = await _mimeStorageService.GetAccountsMimeStorageSizesAsync(accounts.Select(a => a.Id)).ConfigureAwait(false);
var storageItems = accounts
.Select(account =>
{
sizeMap.TryGetValue(account.Id, out var accountSize);
var viewModel = new AccountStorageItemViewModel(account, accountSize, DeleteAllCommand, DeleteOlderThanOneMonthCommand, DeleteOlderThanThreeMonthsCommand, DeleteOlderThanSixMonthsCommand, DeleteOlderThanOneYearCommand);
viewModel.SizeDescription = string.Format(Translator.SettingsStorage_AccountUsageDescription, viewModel.SizeText);
return viewModel;
})
.OrderByDescending(a => a.SizeBytes)
.ToList();
await ExecuteUIThread(() =>
{
MimeRootPath = mimeRootPath;
AccountStorageItems.Clear();
foreach (var item in storageItems)
{
AccountStorageItems.Add(item);
}
var total = storageItems.Sum(a => a.SizeBytes);
SummaryText = storageItems.Count == 0
? Translator.SettingsStorage_NoAccountsFound
: string.Format(Translator.SettingsStorage_TotalUsage, total.GetBytesReadable());
});
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to refresh storage data.");
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsLoading = false; });
}
}
[RelayCommand]
private async Task DeleteAllAsync(AccountStorageItemViewModel accountItem)
{
if (accountItem == null || IsBusy) return;
bool approved = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.SettingsStorage_DeleteAll_Confirm_Message, accountItem.AccountName),
Translator.SettingsStorage_DeleteAll_Confirm_Title,
Translator.Buttons_Delete);
if (!approved) return;
await ExecuteUIThread(() => { IsCleaning = true; });
try
{
await _mimeStorageService.DeleteAccountMimeStorageAsync(accountItem.Account.Id).ConfigureAwait(false);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Info, Translator.SettingsStorage_DeleteAll_Success, Core.Domain.Enums.InfoBarMessageType.Success);
});
await RefreshStorageAsync();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete all MIME content for account {AccountId}", accountItem.Account.Id);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsCleaning = false; });
}
}
[RelayCommand]
private Task DeleteOlderThanOneMonthAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 1);
[RelayCommand]
private Task DeleteOlderThanThreeMonthsAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 3);
[RelayCommand]
private Task DeleteOlderThanSixMonthsAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 6);
[RelayCommand]
private Task DeleteOlderThanOneYearAsync(AccountStorageItemViewModel accountItem)
=> DeleteOlderThanAsync(accountItem, 12);
private async Task DeleteOlderThanAsync(AccountStorageItemViewModel accountItem, int months)
{
if (accountItem == null || IsBusy) return;
string rangeText = GetRangeText(months);
bool approved = await _dialogService.ShowConfirmationDialogAsync(
string.Format(Translator.SettingsStorage_DeleteOld_Confirm_Message, rangeText, accountItem.AccountName),
Translator.SettingsStorage_DeleteOld_Confirm_Title,
Translator.Buttons_Delete);
if (!approved) return;
await ExecuteUIThread(() => { IsCleaning = true; });
try
{
var cutoffDateUtc = DateTime.UtcNow.AddMonths(-months);
var deletedDirectoryCount = await _mimeStorageService
.DeleteAccountMimeStorageOlderThanAsync(accountItem.Account.Id, cutoffDateUtc)
.ConfigureAwait(false);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(
Translator.GeneralTitle_Info,
string.Format(Translator.SettingsStorage_DeleteOld_Success, deletedDirectoryCount, rangeText),
Core.Domain.Enums.InfoBarMessageType.Success);
});
await RefreshStorageAsync();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete MIME content by cutoff for account {AccountId}", accountItem.Account.Id);
await ExecuteUIThread(() =>
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, Core.Domain.Enums.InfoBarMessageType.Error);
});
}
finally
{
await ExecuteUIThread(() => { IsCleaning = false; });
}
}
private static string GetRangeText(int months)
{
return months switch
{
1 => Translator.SettingsStorage_1Month,
3 => Translator.SettingsStorage_3Months,
6 => Translator.SettingsStorage_6Months,
12 => Translator.SettingsStorage_1Year,
_ => string.Format(Translator.SettingsStorage_Months, months)
};
}
}
+65 -3
View File
@@ -21,6 +21,7 @@ using Wino.Mail.Services;
using Wino.Mail.ViewModels;
using Wino.Mail.WinUI.Interfaces;
using Wino.Mail.WinUI.Services;
using Wino.Messaging.UI;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.Server;
using Wino.Services;
@@ -92,7 +93,6 @@ public partial class App : WinoApplication,
services.AddTransient(typeof(AliasManagementPageViewModel));
services.AddTransient(typeof(ContactsPageViewModel));
services.AddTransient(typeof(SignatureAndEncryptionPageViewModel));
services.AddTransient(typeof(CalendarPageViewModel));
services.AddTransient(typeof(CalendarSettingsPageViewModel));
services.AddTransient(typeof(CalendarAccountSettingsPageViewModel));
@@ -362,8 +362,70 @@ public partial class App : WinoApplication,
WeakReferenceMessenger.Default.Register<NewCalendarSynchronizationRequested>(this);
}
public void Receive(NewMailSynchronizationRequested message) => _synchronizationManager?.SynchronizeMailAsync(message.Options);
public void Receive(NewCalendarSynchronizationRequested message) => _synchronizationManager?.SynchronizeCalendarAsync(message.Options);
public async void Receive(NewMailSynchronizationRequested message)
{
if (_synchronizationManager == null) return;
MailSynchronizationResult syncResult;
try
{
syncResult = await _synchronizationManager.SynchronizeMailAsync(message.Options);
}
catch (Exception ex)
{
// Defensive fallback to guarantee completion message emission.
syncResult = MailSynchronizationResult.Failed(ex);
}
WeakReferenceMessenger.Default.Send(new AccountSynchronizationCompleted(
message.Options.AccountId,
syncResult.CompletedState,
message.Options.GroupedSynchronizationTrackingId));
if (syncResult.CompletedState == SynchronizationCompletedState.Failed ||
syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted)
{
var dialogService = Services.GetRequiredService<IMailDialogService>();
var errorMessage = GetSynchronizationFailureMessage(message.Options.Type, syncResult.Exception?.Message);
var severity = syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted
? InfoBarMessageType.Warning
: InfoBarMessageType.Error;
dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, errorMessage, severity);
}
}
public async void Receive(NewCalendarSynchronizationRequested message)
{
if (_synchronizationManager == null) return;
var calendarSyncResult = await _synchronizationManager.SynchronizeCalendarAsync(message.Options);
if (calendarSyncResult.CompletedState == SynchronizationCompletedState.Failed)
{
var dialogService = Services.GetRequiredService<IMailDialogService>();
dialogService.InfoBarMessage(
Translator.Info_SyncFailedTitle,
Translator.Exception_FailedToSynchronizeFolders,
InfoBarMessageType.Error);
}
}
private static string GetSynchronizationFailureMessage(MailSynchronizationType synchronizationType, string? exceptionMessage)
{
if (!string.IsNullOrWhiteSpace(exceptionMessage))
{
return exceptionMessage;
}
return synchronizationType switch
{
MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases,
MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation,
_ => Translator.Exception_FailedToSynchronizeFolders
};
}
/// <summary>
/// Handles activation redirected from another instance (single-instancing).
@@ -66,6 +66,7 @@ public class NavigationService : NavigationServiceBase, INavigationService
WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage),
WinoPage.ContactsPage => typeof(ContactsPage),
WinoPage.SignatureAndEncryptionPage => typeof(SignatureAndEncryptionPage),
WinoPage.StoragePage => typeof(StoragePage),
WinoPage.CalendarPage => typeof(CalendarPage),
WinoPage.EventDetailsPage => typeof(EventDetailsPage),
WinoPage.CalendarSettingsPage => typeof(CalendarSettingsPage),
@@ -15,14 +15,14 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="F1 M 0 9.375 C 0 8.509115 0.110677 7.677409 0.332031 6.879883 C 0.553385 6.082357 0.867513 5.335287 1.274414 4.638672 C 1.681315 3.942059 2.169596 3.30892 2.739258 2.739258 C 3.308919 2.169598 3.942057 1.681316 4.638672 1.274414 C 5.335286 0.867514 6.082356 0.553387 6.879883 0.332031 C 7.677409 0.110678 8.509114 0 9.375 0 C 10.240885 0 11.072591 0.110678 11.870117 0.332031 C 12.667643 0.553387 13.414713 0.867514 14.111328 1.274414 C 14.807942 1.681316 15.44108 2.169598 16.010742 2.739258 C 16.580402 3.30892 17.068684 3.942059 17.475586 4.638672 C 17.882486 5.335287 18.196613 6.082357 18.417969 6.879883 C 18.639322 7.677409 18.75 8.509115 18.75 9.375 C 18.75 10.240886 18.637695 11.072592 18.413086 11.870117 C 18.188477 12.667644 17.872721 13.413086 17.46582 14.106445 C 17.058918 14.799805 16.570637 15.431315 16.000977 16.000977 C 15.431314 16.570639 14.799804 17.05892 14.106445 17.46582 C 13.413085 17.872721 12.666015 18.188477 11.865234 18.413086 C 11.064453 18.637695 10.234375 18.75 9.375 18.75 C 8.509114 18.75 7.675781 18.639322 6.875 18.417969 C 6.074219 18.196615 5.327148 17.882486 4.633789 17.475586 C 3.94043 17.068686 3.308919 16.580404 2.739258 16.010742 C 2.169596 15.441081 1.681315 14.80957 1.274414 14.116211 C 0.867513 13.422852 0.553385 12.675781 0.332031 11.875 C 0.110677 11.074219 0 10.240886 0 9.375 Z M 17.5 9.375 C 17.5 8.626303 17.403971 7.905273 17.211914 7.211914 C 17.019855 6.518556 16.746418 5.87077 16.391602 5.268555 C 16.036783 4.666342 15.613606 4.119467 15.12207 3.62793 C 14.630533 3.136395 14.083658 2.713217 13.481445 2.358398 C 12.879231 2.003582 12.231445 1.730145 11.538086 1.538086 C 10.844727 1.346029 10.123697 1.25 9.375 1.25 C 8.626302 1.25 7.905273 1.346029 7.211914 1.538086 C 6.518555 1.730145 5.870768 2.003582 5.268555 2.358398 C 4.666341 2.713217 4.119466 3.136395 3.62793 3.62793 C 3.136393 4.119467 2.713216 4.666342 2.358398 5.268555 C 2.003581 5.87077 1.730143 6.518556 1.538086 7.211914 C 1.346029 7.905273 1.25 8.626303 1.25 9.375 C 1.25 10.123698 1.346029 10.844727 1.538086 11.538086 C 1.730143 12.231445 2.001953 12.879232 2.353516 13.481445 C 2.705078 14.083659 3.128255 14.632162 3.623047 15.126953 C 4.117838 15.621745 4.666341 16.044922 5.268555 16.396484 C 5.870768 16.748047 6.518555 17.019857 7.211914 17.211914 C 7.905273 17.403971 8.626302 17.5 9.375 17.5 C 10.123697 17.5 10.844727 17.403971 11.538086 17.211914 C 12.231445 17.019857 12.879231 16.748047 13.481445 16.396484 C 14.083658 16.044922 14.63216 15.621745 15.126953 15.126953 C 15.621744 14.632162 16.044922 14.083659 16.396484 13.481445 C 16.748047 12.879232 17.019855 12.231445 17.211914 11.538086 C 17.403971 10.844727 17.5 10.123698 17.5 9.375 Z M 8.4375 5.625 C 8.4375 5.364584 8.528646 5.14323 8.710938 4.960938 C 8.893229 4.778646 9.114583 4.6875 9.375 4.6875 C 9.635416 4.6875 9.856771 4.778646 10.039062 4.960938 C 10.221354 5.14323 10.3125 5.364584 10.3125 5.625 C 10.3125 5.885417 10.221354 6.106771 10.039062 6.289062 C 9.856771 6.471354 9.635416 6.5625 9.375 6.5625 C 9.114583 6.5625 8.893229 6.471354 8.710938 6.289062 C 8.528646 6.106771 8.4375 5.885417 8.4375 5.625 Z M 8.75 13.125 L 8.75 8.125 C 8.75 7.95573 8.811849 7.809246 8.935547 7.685547 C 9.059244 7.56185 9.205729 7.5 9.375 7.5 C 9.544271 7.5 9.690755 7.56185 9.814453 7.685547 C 9.93815 7.809246 10 7.95573 10 8.125 L 10 13.125 C 10 13.294271 9.93815 13.440756 9.814453 13.564453 C 9.690755 13.688151 9.544271 13.75 9.375 13.75 C 9.205729 13.75 9.059244 13.688151 8.935547 13.564453 C 8.811849 13.440756 8.75 13.294271 8.75 13.125 Z "
Fill="#0f80d7" />
Fill="{ThemeResource SystemAccentColor}" />
</DataTemplate>
<DataTemplate x:Key="WinoCustomMessageDialogQuestionIconTemplate">
<Path
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-6.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13M6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.76 2.76 0 0 1 1.637.525c.503.377.863.965.863 1.725c0 .448-.115.83-.329 1.15c-.205.307-.47.513-.692.662c-.109.072-.22.138-.313.195l-.006.004a6 6 0 0 0-.26.16a1 1 0 0 0-.276.245a.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661q.154-.1.313-.195l.007-.004c.1-.061.182-.11.258-.161a1 1 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.61.61 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1 1 0 0 0-.34.398M9 11a1 1 0 1 1-2 0a1 1 0 0 1 2 0"
Fill="#0984e3" />
Fill="{ThemeResource SystemAccentColor}" />
</DataTemplate>
<DataTemplate x:Key="WinoCustomMessageDialogWarningIconTemplate">
<Path
@@ -59,8 +59,8 @@
</Grid.RowDefinitions>
<!-- Title -->
<StackPanel Orientation="Horizontal" Spacing="12">
<Viewbox Width="28" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="8">
<Viewbox Width="22" HorizontalAlignment="Left">
<ContentControl Content="{x:Bind Icon}" ContentTemplateSelector="{StaticResource CustomWinoMessageDialogIconSelector}" />
</Viewbox>
@@ -0,0 +1,6 @@
using Wino.Mail.ViewModels;
using Wino.Mail.WinUI;
namespace Wino.Views.Abstract;
public abstract class StoragePageAbstract : BasePage<StoragePageViewModel> { }
+3 -2
View File
@@ -210,14 +210,14 @@
<Border
x:Name="MailListContainer"
Grid.Column="0"
Margin="2"
Margin="6,2"
Padding="5,5,5,0"
HorizontalAlignment="Stretch"
Background="{ThemeResource WinoContentZoneBackgroud}"
BorderBrush="{StaticResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{ThemeResource OverlayCornerRadius}"
Translation="0,0,50">
Translation="0,0,20">
<Border.Shadow>
<ThemeShadow />
</Border.Shadow>
@@ -421,6 +421,7 @@
HorizontalContentAlignment="Stretch"
toolkitExt:ListViewExtensions.ItemContainerStretchDirection="Horizontal"
toolkitExt:ScrollViewerExtensions.VerticalScrollBarMargin="0"
CanDragItems="True"
ChoosingItemContainer="WinoListViewChoosingItemContainer"
IsItemClickEnabled="True"
ItemClick="WinoListViewItemClicked"
@@ -409,6 +409,38 @@
Glyph="&#xE76C;" />
</Grid>
</Button>
<Button
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
Click="SettingOptionClicked"
CornerRadius="4"
Tag="{x:Bind enums:WinoPage.StoragePage}">
<Grid Padding="4,10" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
VerticalAlignment="Center"
FontSize="16"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE81C;" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Bind domain:Translator.SettingsStorage_Title}" />
<FontIcon
Grid.Column="2"
VerticalAlignment="Center"
FontSize="12"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
Glyph="&#xE76C;" />
</Grid>
</Button>
</StackPanel>
</Grid>
@@ -0,0 +1,113 @@
<abstract:StoragePageAbstract
x:Class="Wino.Views.Settings.StoragePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:coreControls="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:mailData="using:Wino.Mail.ViewModels.Data"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="root"
mc:Ignorable="d">
<ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<Grid
Padding="16"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{ThemeResource OverlayCornerRadius}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Spacing="12">
<PathIcon>
<PathIcon.Data>
M5 7h14a3 3 0 0 1 2.995 2.824L22 10v4a3 3 0 0 1-2.824 2.995L19 17H5a3 3 0 0 1-2.995-2.824L2 14v-4a3 3 0 0 1 2.824-2.995L5 7h14H5Zm14 1.5H5A1.5 1.5 0 0 0 3.5 10v4A1.5 1.5 0 0 0 5 15.5h14a1.5 1.5 0 0 0 1.5-1.5v-4A1.5 1.5 0 0 0 19 8.5ZM18 10a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z
</PathIcon.Data>
</PathIcon>
<StackPanel Spacing="2">
<TextBlock Style="{ThemeResource BodyStrongTextBlockStyle}" Text="{x:Bind domain:Translator.SettingsStorage_Title}" />
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind domain:Translator.SettingsStorage_Description}" TextWrapping="WrapWholeWords" />
</StackPanel>
</StackPanel>
<Grid
Grid.Row="1"
Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.SummaryText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<Button
Grid.Column="1"
Margin="12,0,0,0"
Command="{x:Bind ViewModel.RefreshStorageCommand}"
Content="{x:Bind domain:Translator.SettingsStorage_ScanFolder}"
IsEnabled="{x:Bind ViewModel.IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}"
Style="{StaticResource AccentButtonStyle}" />
</Grid>
</Grid>
<ItemsControl ItemsSource="{x:Bind ViewModel.AccountStorageItems, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="mailData:AccountStorageItemViewModel">
<controls:SettingsExpander Description="{x:Bind SizeDescription}" Header="{x:Bind AccountName}">
<controls:SettingsExpander.HeaderIcon>
<coreControls:WinoFontIcon FontSize="64" Icon="{x:Bind helpers:XamlHelpers.GetProviderIcon(Account)}" />
</controls:SettingsExpander.HeaderIcon>
<controls:SettingsExpander.Items>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsStorage_DeleteAll_Description}" Header="{x:Bind domain:Translator.SettingsStorage_DeleteAll_Title}">
<Button
Command="{x:Bind DeleteAllCommand}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.SettingsStorage_DeleteAll_Button}"
IsEnabled="{x:Bind IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}" />
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsStorage_DeleteOld_Description}" Header="{x:Bind domain:Translator.SettingsStorage_DeleteOld_Title}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
Command="{x:Bind DeleteOneMonthCommand}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.SettingsStorage_DeleteOld_1Month}"
IsEnabled="{x:Bind IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}" />
<Button
Command="{x:Bind DeleteThreeMonthsCommand}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.SettingsStorage_DeleteOld_3Months}"
IsEnabled="{x:Bind IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}" />
<Button
Command="{x:Bind DeleteSixMonthsCommand}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.SettingsStorage_DeleteOld_6Months}"
IsEnabled="{x:Bind IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}" />
<Button
Command="{x:Bind DeleteYearCommand}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.SettingsStorage_DeleteOld_1Year}"
IsEnabled="{x:Bind IsBusy, Mode=OneWay, Converter={StaticResource ReverseBooleanConverter}}" />
</StackPanel>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</abstract:StoragePageAbstract>
@@ -0,0 +1,11 @@
using Wino.Views.Abstract;
namespace Wino.Views.Settings;
public sealed partial class StoragePage : StoragePageAbstract
{
public StoragePage()
{
InitializeComponent();
}
}
@@ -47,6 +47,9 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
case WinoPage.PersonalizationPage:
WeakReferenceMessenger.Default.Send(new BreadcrumbNavigationRequested(Translator.SettingsPersonalization_Title, WinoPage.PersonalizationPage));
break;
case WinoPage.StoragePage:
WeakReferenceMessenger.Default.Send(new BreadcrumbNavigationRequested(Translator.SettingsStorage_Title, WinoPage.StoragePage));
break;
}
}
}
+13
View File
@@ -1183,4 +1183,17 @@ public class MailService : BaseDatabaseService, IMailService
return await Connection.QueryScalarsAsync<string>(sql, mailCopyIds.Cast<object>().ToArray());
}
public Task<List<MailCopy>> GetMailCopiesBeforeDateAsync(Guid accountId, DateTime cutoffDateUtc)
{
const string query = """
SELECT MailCopy.*
FROM MailCopy
INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id
WHERE MailItemFolder.MailAccountId = ?
AND MailCopy.CreationDate < ?
""";
return Connection.QueryAsync<MailCopy>(query, accountId, cutoffDateUtc);
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
namespace Wino.Services;
public class MimeStorageService : IMimeStorageService
{
private readonly INativeAppService _nativeAppService;
private readonly IMailService _mailService;
private readonly ILogger _logger = Log.ForContext<MimeStorageService>();
public MimeStorageService(INativeAppService nativeAppService, IMailService mailService)
{
_nativeAppService = nativeAppService;
_mailService = mailService;
}
public Task<string> GetMimeRootPathAsync() => _nativeAppService.GetMimeMessageStoragePath();
public async Task<Dictionary<Guid, long>> GetAccountsMimeStorageSizesAsync(IEnumerable<Guid> accountIds)
{
var mimeRoot = await GetMimeRootPathAsync().ConfigureAwait(false);
var result = new Dictionary<Guid, long>();
foreach (var accountId in accountIds)
{
var accountPath = Path.Combine(mimeRoot, accountId.ToString());
result[accountId] = GetDirectorySizeSafe(accountPath);
}
return result;
}
public async Task DeleteAccountMimeStorageAsync(Guid accountId)
{
var mimeRoot = await GetMimeRootPathAsync().ConfigureAwait(false);
var accountPath = Path.Combine(mimeRoot, accountId.ToString());
if (Directory.Exists(accountPath))
{
Directory.Delete(accountPath, true);
}
}
public async Task<int> DeleteAccountMimeStorageOlderThanAsync(Guid accountId, DateTime cutoffDateUtc)
{
var mailCopies = await _mailService.GetMailCopiesBeforeDateAsync(accountId, cutoffDateUtc).ConfigureAwait(false);
if (mailCopies.Count == 0)
return 0;
var mimeRoot = await GetMimeRootPathAsync().ConfigureAwait(false);
var accountPath = Path.Combine(mimeRoot, accountId.ToString());
var fileIds = mailCopies.Select(a => a.FileId).Distinct().ToList();
int deletedFolderCount = 0;
foreach (var fileId in fileIds)
{
var mimeDirectory = Path.Combine(accountPath, fileId.ToString());
if (!Directory.Exists(mimeDirectory))
continue;
try
{
Directory.Delete(mimeDirectory, true);
deletedFolderCount++;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to delete MIME directory {DirectoryPath}", mimeDirectory);
}
}
return deletedFolderCount;
}
private static long GetDirectorySizeSafe(string directoryPath)
{
if (!Directory.Exists(directoryPath))
return 0;
long total = 0;
try
{
foreach (var filePath in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
{
try
{
total += new FileInfo(filePath).Length;
}
catch
{
// Ignore unreadable files and continue calculating.
}
}
}
catch
{
return 0;
}
return total;
}
}
+1
View File
@@ -14,6 +14,7 @@ public static class ServicesContainerSetup
services.AddSingleton<IWinoLogger, WinoLogger>();
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
services.AddSingleton<IMimeFileService, MimeFileService>();
services.AddTransient<IMimeStorageService, MimeStorageService>();
services.AddTransient<ICalendarService, CalendarService>();
services.AddTransient<IMailService, MailService>();