diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs index da08a933..5f9a90b0 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountDataSyncService.cs @@ -7,5 +7,7 @@ namespace Wino.Core.Domain.Interfaces; public interface IWinoAccountDataSyncService { Task ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); + Task ExportToJsonAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); Task ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); + Task ImportFromJsonAsync(string jsonContent, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountSyncFileExportResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncFileExportResult.cs new file mode 100644 index 00000000..70fc8929 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoAccountSyncFileExportResult.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class WinoAccountSyncFileExportResult +{ + public string JsonContent { get; init; } = string.Empty; + public WinoAccountSyncExportResult ExportResult { get; init; } = new(); +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index f43a9acc..418a6da1 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1339,9 +1339,10 @@ "WelcomeWindow_GetStartedButton": "Get started by adding an account", "WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.", "WelcomeWindow_ImportFromWinoAccount": "Import from your Wino Account", - "WelcomeWindow_ImportInProgress": "Importing your synchronized preferences and accounts...", - "WelcomeWindow_ImportNoAccountsFound": "No synced accounts were found in your Wino Account. If preferences were available, they were restored. Use Get started to add an account manually.", - "WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} synced accounts are already available on this device. Use Get started to add another account manually if needed.", + "WelcomeWindow_ImportFromJsonFile": "Import from a JSON file", + "WelcomeWindow_ImportInProgress": "Importing preferences and accounts...", + "WelcomeWindow_ImportNoAccountsFound": "No accounts were found to import. If preferences were available, they were restored. Use Get started to add an account manually.", + "WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} imported accounts are already available on this device. Use Get started to add another account manually if needed.", "WelcomeWindow_SetupTitle": "Set up your account", "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_AddAccountButton": "Add account", @@ -1400,13 +1401,13 @@ "WinoAccount_Management_StatusLabel": "Status: {0}", "WinoAccount_Management_NoRemoteSettings": "There is no synchronized data stored for this account yet.", "WinoAccount_Management_ExportSucceeded": "Your selected Wino data was exported successfully.", - "WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported to your Wino Account.", - "WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details to your Wino Account.", + "WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported.", + "WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details.", "WinoAccount_Management_ImportSucceeded": "Imported synchronized data from your Wino Account.", - "WinoAccount_Management_ImportPreferencesSucceeded": "Applied {0} synchronized preferences.", + "WinoAccount_Management_ImportPreferencesSucceeded": "Applied {0} preferences.", "WinoAccount_Management_ImportAccountsSucceeded": "Imported {0} accounts.", "WinoAccount_Management_ImportDuplicateAccountsSkipped": "Skipped {0} accounts that already exist on this device.", - "WinoAccount_Management_ImportPartial": "Applied {0} synchronized preferences. {1} preferences could not be restored.", + "WinoAccount_Management_ImportPartial": "Applied {0} preferences. {1} preferences could not be restored.", "WinoAccount_Management_ImportReloginReminder": "Passwords, tokens, and other sensitive information were not imported. Sign in again for each account on this device before using it.", "WinoAccount_Management_SerializeFailed": "Wino could not serialize your current preferences.", "WinoAccount_Management_EmptyExport": "There are no preference values to export.", @@ -1418,6 +1419,12 @@ "WinoAccount_Management_ExportDialog_AccountsDisclaimer": "Passwords, tokens, and other sensitive information are not synced.", "WinoAccount_Management_ExportDialog_AccountsRelogin": "Imported accounts on another PC will still need you to sign in again before they can be used.", "WinoAccount_Management_ExportDialog_InProgress": "Exporting your selected Wino data...", + "WinoAccount_Management_LocalDataSectionTitle": "Transfer with a JSON file", + "WinoAccount_Management_LocalDataSectionDescription": "Import from or export to a local JSON file. Passwords, tokens, and other sensitive information are not included.", + "WinoAccount_Management_LocalDataImportAction": "Import JSON", + "WinoAccount_Management_LocalDataExportAction": "Export JSON", + "WinoAccount_Management_LocalDataSaved": "Saved your exported Wino data to {0}.", + "WinoAccount_Management_LocalDataInvalidFile": "The selected JSON file doesn't contain a valid Wino export.", "WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.", "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "WinoAccount_SettingsSection_Title": "Wino Account", diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index 2c531298..e350d91a 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Serilog; @@ -26,6 +31,10 @@ namespace Wino.Mail.ViewModels; public partial class AccountManagementViewModel : AccountManagementPageViewModelBase { + private const string LocalExportFileName = "wino-data-export.json"; + private static readonly UTF8Encoding Utf8WithoutBom = new(false); + + private readonly IWinoAccountDataSyncService _syncService; private readonly IWinoLogger _winoLogger; private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; private readonly ICalDavClient _calDavClient; @@ -38,6 +47,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel IProviderService providerService, IStoreManagementService storeManagementService, IWinoAccountProfileService winoAccountProfileService, + IWinoAccountDataSyncService syncService, IWinoLogger winoLogger, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ICalDavClient calDavClient, @@ -45,11 +55,17 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService) { MailDialogService = dialogService; + _syncService = syncService; _winoLogger = winoLogger; _specialImapProviderConfigResolver = specialImapProviderConfigResolver; _calDavClient = calDavClient; } + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))] + [NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))] + public partial bool IsDataTransferInProgress { get; set; } + [RelayCommand] private async Task CreateMergedAccountAsync() { @@ -208,6 +224,95 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel [RelayCommand(CanExecute = nameof(CanReorderAccounts))] private Task ReorderAccountsAsync() => MailDialogService.ShowAccountReorderDialogAsync(availableAccounts: Accounts); + [RelayCommand(CanExecute = nameof(CanTransferLocalData))] + private async Task ExportLocalDataAsync() + { + try + { + var exportPath = await ExecuteUIThreadTaskAsync( + () => MailDialogService.PickFilePathAsync(LocalExportFileName)) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(exportPath)) + { + return; + } + + await ExecuteUIThread(() => IsDataTransferInProgress = true); + + var exportResult = await _syncService.ExportToJsonAsync(new()).ConfigureAwait(false); + await File.WriteAllTextAsync(exportPath, exportResult.JsonContent, Utf8WithoutBom).ConfigureAwait(false); + + DialogService.InfoBarMessage( + Translator.GeneralTitle_Info, + $"{BuildExportSuccessMessage(exportResult.ExportResult)} {string.Format(Translator.WinoAccount_Management_LocalDataSaved, exportPath)}", + InfoBarMessageType.Success); + } + catch (Exception ex) + { + DialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + ex.Message, + InfoBarMessageType.Error); + } + finally + { + await ExecuteUIThread(() => IsDataTransferInProgress = false); + } + } + + [RelayCommand(CanExecute = nameof(CanTransferLocalData))] + private async Task ImportLocalDataAsync() + { + try + { + var fileContent = await ExecuteUIThreadTaskAsync( + () => MailDialogService.PickWindowsFileContentAsync(".json")) + .ConfigureAwait(false); + + if (fileContent.Length == 0) + { + return; + } + + await ExecuteUIThread(() => IsDataTransferInProgress = true); + + var jsonContent = Encoding.UTF8.GetString(fileContent); + var result = await _syncService.ImportFromJsonAsync(jsonContent).ConfigureAwait(false); + + await InitializeAccountsAsync().ConfigureAwait(false); + + var messageType = result.FailedPreferenceCount > 0 + ? InfoBarMessageType.Warning + : InfoBarMessageType.Success; + + DialogService.InfoBarMessage( + result.FailedPreferenceCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info, + BuildImportMessage(result), + messageType); + } + catch (JsonException) + { + DialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_LocalDataInvalidFile, + InfoBarMessageType.Error); + } + catch (Exception ex) + { + DialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + ex.Message, + InfoBarMessageType.Error); + } + finally + { + await ExecuteUIThread(() => IsDataTransferInProgress = false); + } + } + + private bool CanTransferLocalData() => !IsDataTransferInProgress; + public override void OnNavigatedFrom(NavigationMode mode, object parameters) { base.OnNavigatedFrom(mode, parameters); @@ -294,4 +399,60 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel await ManageStorePurchasesAsync().ConfigureAwait(false); } + + private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result) + { + var parts = new Collection(); + + if (result.IncludedPreferences) + { + parts.Add(Translator.WinoAccount_Management_ExportPreferencesSucceeded); + } + + if (result.IncludedAccounts) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ExportAccountsSucceeded, result.ExportedMailboxCount)); + } + + if (parts.Count == 0) + { + parts.Add(Translator.WinoAccount_Management_ExportSucceeded); + } + + return string.Join(" ", parts); + } + + private static string BuildImportMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncImportResult result) + { + var parts = new Collection(); + + if (result.HadRemotePreferences) + { + parts.Add(result.FailedPreferenceCount > 0 + ? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount) + : string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount)); + } + + if (result.ImportedMailboxCount > 0) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ImportAccountsSucceeded, result.ImportedMailboxCount)); + } + + if (result.SkippedDuplicateMailboxCount > 0) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount)); + } + + if (parts.Count == 0) + { + parts.Add(Translator.WinoAccount_Management_ImportEmpty); + } + + if (result.ImportedMailboxCount > 0) + { + parts.Add(Translator.WinoAccount_Management_ImportReloginReminder); + } + + return string.Join(" ", parts); + } } diff --git a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs index 9626af2a..1784aa32 100644 --- a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs +++ b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -27,6 +30,7 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(GetStartedCommand))] [NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))] + [NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))] public partial bool IsImportInProgress { get; set; } [ObservableProperty] @@ -101,6 +105,49 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel } } + [RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))] + private async Task ImportFromJsonAsync() + { + await ExecuteUIThread(() => ImportStatusMessage = string.Empty); + + try + { + var fileContent = await _dialogService.PickWindowsFileContentAsync(".json"); + if (fileContent.Length == 0) + { + return; + } + + await ExecuteUIThread(() => IsImportInProgress = true); + + var jsonContent = Encoding.UTF8.GetString(fileContent); + var result = await _syncService.ImportFromJsonAsync(jsonContent); + if (result.ImportedMailboxCount > 0) + { + ReportUIChange(new WelcomeImportCompletedMessage(result.ImportedMailboxCount)); + return; + } + + await ExecuteUIThread(() => ImportStatusMessage = BuildInlineImportMessage(result)); + } + catch (JsonException ex) + { + Debug.WriteLine(ex.Message); + await _dialogService.ShowMessageAsync( + Translator.WinoAccount_Management_LocalDataInvalidFile, + Translator.GeneralTitle_Error, + WinoCustomMessageDialogIcon.Error); + } + catch (Exception ex) + { + await _dialogService.ShowMessageAsync(ex.Message, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error); + } + finally + { + await ExecuteUIThread(() => IsImportInProgress = false); + } + } + private bool CanOpenWelcomeActions() => !IsImportInProgress; private static string BuildInlineImportMessage(WinoAccountSyncImportResult result) diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index 4d468418..4a0df902 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -23,7 +23,7 @@ + Version="2.0.4.0" /> diff --git a/Wino.Mail.WinUI/Views/Abstract/ManageAccountsPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/ManageAccountsPageAbstract.cs index 4d61c001..5df7ff52 100644 --- a/Wino.Mail.WinUI/Views/Abstract/ManageAccountsPageAbstract.cs +++ b/Wino.Mail.WinUI/Views/Abstract/ManageAccountsPageAbstract.cs @@ -1,7 +1,7 @@ -using Wino.Core.ViewModels; +using Wino.Mail.ViewModels; namespace Wino.Mail.WinUI.Views.Abstract; -public abstract class ManageAccountsPageAbstract : BasePage +public abstract class ManageAccountsPageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml index 1587bf30..e06f212e 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml @@ -220,6 +220,21 @@ + + +