Create sub folder, delete folder, storage settings, some ui adjustments on threads.
This commit is contained in:
@@ -19,6 +19,8 @@ public enum FolderSynchronizerOperation
|
||||
RenameFolder,
|
||||
EmptyFolder,
|
||||
MarkFolderRead,
|
||||
DeleteFolder,
|
||||
CreateSubFolder,
|
||||
}
|
||||
|
||||
public enum CalendarSynchronizerOperation
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,26 +1853,76 @@ 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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Gmail, a single mail can have multiple labels (folders).
|
||||
// Each label requires a separate MailCopy entry in the database with:
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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> { }
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user