Add local JSON account import and export

This commit is contained in:
Burak Kaan Köse
2026-04-18 15:55:15 +02:00
parent 2a93600ede
commit 00437bae4e
14 changed files with 443 additions and 48 deletions
@@ -7,5 +7,7 @@ namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountDataSyncService public interface IWinoAccountDataSyncService
{ {
Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default);
Task<WinoAccountSyncFileExportResult> ExportToJsonAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default);
Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default); Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default);
Task<WinoAccountSyncImportResult> ImportFromJsonAsync(string jsonContent, CancellationToken cancellationToken = default);
} }
@@ -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();
}
@@ -1339,9 +1339,10 @@
"WelcomeWindow_GetStartedButton": "Get started by adding an account", "WelcomeWindow_GetStartedButton": "Get started by adding an account",
"WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.", "WelcomeWindow_GetStartedDescription": "Add your Outlook, Gmail, or IMAP account to get started with Wino Mail.",
"WelcomeWindow_ImportFromWinoAccount": "Import from your Wino Account", "WelcomeWindow_ImportFromWinoAccount": "Import from your Wino Account",
"WelcomeWindow_ImportInProgress": "Importing your synchronized preferences and accounts...", "WelcomeWindow_ImportFromJsonFile": "Import from a JSON file",
"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_ImportInProgress": "Importing preferences and accounts...",
"WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} synced accounts are already available on this device. Use Get started to add another account manually if needed.", "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_SetupTitle": "Set up your account",
"WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started",
"WelcomeWindow_AddAccountButton": "Add account", "WelcomeWindow_AddAccountButton": "Add account",
@@ -1400,13 +1401,13 @@
"WinoAccount_Management_StatusLabel": "Status: {0}", "WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There is no synchronized data stored for this account yet.", "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_ExportSucceeded": "Your selected Wino data was exported successfully.",
"WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported to your Wino Account.", "WinoAccount_Management_ExportPreferencesSucceeded": "Your preferences were exported.",
"WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details to your Wino Account.", "WinoAccount_Management_ExportAccountsSucceeded": "Exported {0} account details.",
"WinoAccount_Management_ImportSucceeded": "Imported synchronized data from your Wino Account.", "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_ImportAccountsSucceeded": "Imported {0} accounts.",
"WinoAccount_Management_ImportDuplicateAccountsSkipped": "Skipped {0} accounts that already exist on this device.", "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_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_SerializeFailed": "Wino could not serialize your current preferences.",
"WinoAccount_Management_EmptyExport": "There are no preference values to export.", "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_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_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_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_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
"WinoAccount_SettingsSection_Title": "Wino Account", "WinoAccount_SettingsSection_Title": "Wino Account",
@@ -1,8 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Serilog; using Serilog;
@@ -26,6 +31,10 @@ namespace Wino.Mail.ViewModels;
public partial class AccountManagementViewModel : AccountManagementPageViewModelBase 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 IWinoLogger _winoLogger;
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly ICalDavClient _calDavClient; private readonly ICalDavClient _calDavClient;
@@ -38,6 +47,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IProviderService providerService, IProviderService providerService,
IStoreManagementService storeManagementService, IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService, IWinoAccountProfileService winoAccountProfileService,
IWinoAccountDataSyncService syncService,
IWinoLogger winoLogger, IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient, ICalDavClient calDavClient,
@@ -45,11 +55,17 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService) IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService)
{ {
MailDialogService = dialogService; MailDialogService = dialogService;
_syncService = syncService;
_winoLogger = winoLogger; _winoLogger = winoLogger;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver; _specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_calDavClient = calDavClient; _calDavClient = calDavClient;
} }
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))]
public partial bool IsDataTransferInProgress { get; set; }
[RelayCommand] [RelayCommand]
private async Task CreateMergedAccountAsync() private async Task CreateMergedAccountAsync()
{ {
@@ -208,6 +224,95 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
[RelayCommand(CanExecute = nameof(CanReorderAccounts))] [RelayCommand(CanExecute = nameof(CanReorderAccounts))]
private Task ReorderAccountsAsync() => MailDialogService.ShowAccountReorderDialogAsync(availableAccounts: Accounts); 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) public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{ {
base.OnNavigatedFrom(mode, parameters); base.OnNavigatedFrom(mode, parameters);
@@ -294,4 +399,60 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
await ManageStorePurchasesAsync().ConfigureAwait(false); await ManageStorePurchasesAsync().ConfigureAwait(false);
} }
private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result)
{
var parts = new Collection<string>();
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<string>();
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);
}
} }
@@ -1,5 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -27,6 +30,7 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(GetStartedCommand))] [NotifyCanExecuteChangedFor(nameof(GetStartedCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))] [NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))]
public partial bool IsImportInProgress { get; set; } public partial bool IsImportInProgress { get; set; }
[ObservableProperty] [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 bool CanOpenWelcomeActions() => !IsImportInProgress;
private static string BuildInlineImportMessage(WinoAccountSyncImportResult result) private static string BuildInlineImportMessage(WinoAccountSyncImportResult result)
+1 -1
View File
@@ -23,7 +23,7 @@
<Identity <Identity
Name="58272BurakKSE.WinoMailPreview" Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=bkaan" Publisher="CN=bkaan"
Version="2.0.3.0" /> Version="2.0.4.0" />
<mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> <mp:PhoneIdentity PhoneProductId="3879fcfb-a561-4599-9103-e0c9b35a271f" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
@@ -1,7 +1,7 @@
using Wino.Core.ViewModels; using Wino.Mail.ViewModels;
namespace Wino.Mail.WinUI.Views.Abstract; namespace Wino.Mail.WinUI.Views.Abstract;
public abstract class ManageAccountsPageAbstract : BasePage<ManageAccountsPagePageViewModel> public abstract class ManageAccountsPageAbstract : BasePage<AccountManagementViewModel>
{ {
} }
@@ -220,6 +220,21 @@
<SymbolIcon Symbol="Account" /> <SymbolIcon Symbol="Account" />
</winuiControls:SettingsCard.HeaderIcon> </winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard> </winuiControls:SettingsCard>
<winuiControls:SettingsCard
Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}"
Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
Command="{x:Bind ViewModel.ImportLocalDataCommand}"
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
<Button
Command="{x:Bind ViewModel.ExportLocalDataCommand}"
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataExportAction}" />
</StackPanel>
<winuiControls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Sync" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
<winuiControls:SettingsCard <winuiControls:SettingsCard
Command="{x:Bind ViewModel.CreateMergedAccountCommand}" Command="{x:Bind ViewModel.CreateMergedAccountCommand}"
Description="{x:Bind domain:Translator.SettingsLinkAccounts_Description}" Description="{x:Bind domain:Translator.SettingsLinkAccounts_Description}"
+3 -3
View File
@@ -162,12 +162,12 @@
</AppBarButton> </AppBarButton>
<AppBarToggleButton <AppBarToggleButton
x:Name="ComposeAiActionsToggleButton" x:Name="ComposeAiActionsToggleButton"
Checked="ComposeAiActionsToggleButton_Checked"
MinWidth="40" MinWidth="40"
HorizontalContentAlignment="Center" HorizontalContentAlignment="Center"
Checked="ComposeAiActionsToggleButton_Checked"
LabelPosition="Collapsed" LabelPosition="Collapsed"
Visibility="{x:Bind GetAiActionsToggleVisibility(ViewModel.PreferencesService.IsAiActionsPanelHidden), Mode=OneWay}" ToolTipService.ToolTip="{x:Bind domain:Translator.Composer_AiActions}"
ToolTipService.ToolTip="{x:Bind domain:Translator.Composer_AiActions}"> Visibility="{x:Bind GetAiActionsToggleVisibility(ViewModel.PreferencesService.IsAiActionsPanelHidden), Mode=OneWay}">
<AppBarToggleButton.Icon> <AppBarToggleButton.Icon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" /> <FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</AppBarToggleButton.Icon> </AppBarToggleButton.Icon>
+19 -1
View File
@@ -4,7 +4,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Mail.WinUI.Views.Abstract" xmlns:abstract="using:Wino.Mail.WinUI.Views.Abstract"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:winuiControls="using:CommunityToolkit.WinUI.Controls"
Style="{StaticResource PageStyle}" Style="{StaticResource PageStyle}"
mc:Ignorable="d"> mc:Ignorable="d">
@@ -12,6 +14,22 @@
<Grid <Grid
MaxWidth="900" MaxWidth="900"
Padding="20" Padding="20"
HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch">
<winuiControls:SettingsCard
Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}"
Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
Command="{x:Bind ViewModel.ImportLocalDataCommand}"
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
<Button
Command="{x:Bind ViewModel.ExportLocalDataCommand}"
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataExportAction}" />
</StackPanel>
<winuiControls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Sync" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
</Grid>
</Border> </Border>
</abstract:ManageAccountsPageAbstract> </abstract:ManageAccountsPageAbstract>
+8 -2
View File
@@ -126,13 +126,17 @@
<StackPanel <StackPanel
Grid.Row="3" Grid.Row="3"
MaxWidth="600" MaxWidth="600"
HorizontalAlignment="Center" HorizontalAlignment="Center">
Spacing="8">
<HyperlinkButton <HyperlinkButton
HorizontalAlignment="Center" HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}" Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
Content="{x:Bind domain:Translator.WelcomeWindow_ImportFromWinoAccount}" /> Content="{x:Bind domain:Translator.WelcomeWindow_ImportFromWinoAccount}" />
<HyperlinkButton
HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ImportFromJsonCommand}"
Content="{x:Bind domain:Translator.WelcomeWindow_ImportFromJsonFile}" />
<StackPanel <StackPanel
x:Name="ImportProgressPanel" x:Name="ImportProgressPanel"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
@@ -157,6 +161,7 @@
Visibility="{x:Bind ViewModel.HasImportStatus, Mode=OneWay}" /> Visibility="{x:Bind ViewModel.HasImportStatus, Mode=OneWay}" />
<TextBlock <TextBlock
Margin="0,4"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}" Style="{StaticResource BodyTextBlockStyle}"
@@ -164,6 +169,7 @@
<Button <Button
MinWidth="240" MinWidth="240"
Margin="0,12,0,0"
Padding="12,10" Padding="12,10"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Command="{x:Bind ViewModel.GetStartedCommand}" Command="{x:Bind ViewModel.GetStartedCommand}"
-1
View File
@@ -378,7 +378,6 @@
<AppxBundle>Always</AppxBundle> <AppxBundle>Always</AppxBundle>
<AppxBundlePlatforms>x86|x64|arm64</AppxBundlePlatforms> <AppxBundlePlatforms>x86|x64|arm64</AppxBundlePlatforms>
<HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks> <HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks>
<PackageCertificateThumbprint>58CE57FA3E5BF2393C86FC3B3161A925679FB3D4</PackageCertificateThumbprint>
<PackageCertificateKeyFile>Wino.Mail.WinUI_TemporaryKey.pfx</PackageCertificateKeyFile> <PackageCertificateKeyFile>Wino.Mail.WinUI_TemporaryKey.pfx</PackageCertificateKeyFile>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+2
View File
@@ -1,5 +1,6 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -600,6 +601,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope<UserMailboxSyncListDto>))] [JsonSerializable(typeof(ApiEnvelope<UserMailboxSyncListDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))] [JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
[JsonSerializable(typeof(ReplaceUserMailboxesRequestDto))] [JsonSerializable(typeof(ReplaceUserMailboxesRequestDto))]
[JsonSerializable(typeof(List<UserMailboxSyncItemDto>))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext; internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey); internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey);
+162 -31
View File
@@ -2,7 +2,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
@@ -18,6 +21,7 @@ namespace Wino.Services;
public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService
{ {
private const int DefaultMaxConcurrentClients = 5; private const int DefaultMaxConcurrentClients = 5;
private const int LocalExportVersion = 1;
private readonly IWinoAccountProfileService _profileService; private readonly IWinoAccountProfileService _profileService;
private readonly IPreferencesService _preferencesService; private readonly IPreferencesService _preferencesService;
@@ -35,37 +39,159 @@ public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService
public async Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default) public async Task<WinoAccountSyncExportResult> ExportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default)
{ {
var exportedMailboxCount = 0; var preparedExport = await PrepareExportAsync(selection).ConfigureAwait(false);
if (selection.IncludePreferences) if (selection.IncludePreferences && preparedExport.PreferencesJson != null)
{ {
await _profileService.SaveSettingsAsync(_preferencesService.ExportPreferences(), cancellationToken).ConfigureAwait(false); await _profileService.SaveSettingsAsync(preparedExport.PreferencesJson, cancellationToken).ConfigureAwait(false);
} }
if (selection.IncludeAccounts) if (selection.IncludeAccounts)
{ {
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var request = new ReplaceUserMailboxesRequestDto var request = new ReplaceUserMailboxesRequestDto
{ {
Mailboxes = accounts Mailboxes = preparedExport.Mailboxes
.OrderBy(a => a.Order)
.Select(MapMailbox)
.ToList()
}; };
await _profileService.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false); await _profileService.ReplaceMailboxesAsync(request, cancellationToken).ConfigureAwait(false);
exportedMailboxCount = request.Mailboxes.Count;
} }
return new WinoAccountSyncExportResult return preparedExport.ExportResult;
}
public async Task<WinoAccountSyncFileExportResult> ExportToJsonAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var preparedExport = await PrepareExportAsync(selection).ConfigureAwait(false);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
{ {
IncludedPreferences = selection.IncludePreferences, writer.WriteStartObject();
IncludedAccounts = selection.IncludeAccounts, writer.WriteNumber("version", LocalExportVersion);
ExportedMailboxCount = exportedMailboxCount writer.WriteString("exportedAtUtc", DateTime.UtcNow);
writer.WriteBoolean("includesPreferences", preparedExport.ExportResult.IncludedPreferences);
writer.WriteBoolean("includesAccounts", preparedExport.ExportResult.IncludedAccounts);
writer.WritePropertyName("preferences");
if (!string.IsNullOrWhiteSpace(preparedExport.PreferencesJson))
{
using var preferencesDocument = JsonDocument.Parse(preparedExport.PreferencesJson);
preferencesDocument.RootElement.WriteTo(writer);
}
else
{
writer.WriteNullValue();
}
writer.WritePropertyName("mailboxes");
JsonSerializer.Serialize(writer, preparedExport.Mailboxes, WinoAccountApiJsonContext.Default.ListUserMailboxSyncItemDto);
writer.WriteEndObject();
}
return new WinoAccountSyncFileExportResult
{
JsonContent = Encoding.UTF8.GetString(stream.ToArray()),
ExportResult = preparedExport.ExportResult
}; };
} }
public async Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default) public async Task<WinoAccountSyncImportResult> ImportAsync(WinoAccountSyncSelection selection, CancellationToken cancellationToken = default)
{
string? settingsJson = null;
List<UserMailboxSyncItemDto> orderedMailboxes = [];
if (selection.IncludePreferences)
{
settingsJson = await _profileService.GetSettingsAsync(cancellationToken).ConfigureAwait(false);
}
if (selection.IncludeAccounts)
{
var mailboxes = await _profileService.GetMailboxesAsync(cancellationToken).ConfigureAwait(false);
orderedMailboxes = mailboxes.Mailboxes
.OrderBy(a => a.SortOrder)
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.ToList();
}
return await ImportDataAsync(selection, settingsJson, orderedMailboxes, cancellationToken).ConfigureAwait(false);
}
public async Task<WinoAccountSyncImportResult> ImportFromJsonAsync(string jsonContent, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
jsonContent = TrimUtf8Bom(jsonContent);
using var document = JsonDocument.Parse(jsonContent);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new JsonException("Invalid root element.");
}
string? settingsJson = null;
if (document.RootElement.TryGetProperty("preferences", out var preferencesElement))
{
settingsJson = preferencesElement.ValueKind switch
{
JsonValueKind.Object => preferencesElement.GetRawText(),
JsonValueKind.String => preferencesElement.GetString(),
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => throw new JsonException("Invalid preferences payload.")
};
}
var mailboxes = new List<UserMailboxSyncItemDto>();
if (document.RootElement.TryGetProperty("mailboxes", out var mailboxesElement))
{
if (mailboxesElement.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null or JsonValueKind.Undefined))
{
throw new JsonException("Invalid mailboxes payload.");
}
if (mailboxesElement.ValueKind == JsonValueKind.Array)
{
mailboxes = JsonSerializer.Deserialize(mailboxesElement.GetRawText(), WinoAccountApiJsonContext.Default.ListUserMailboxSyncItemDto) ?? [];
}
}
var selection = new WinoAccountSyncSelection(
IncludePreferences: !string.IsNullOrWhiteSpace(settingsJson),
IncludeAccounts: mailboxes.Count > 0);
return await ImportDataAsync(selection, settingsJson, mailboxes, cancellationToken).ConfigureAwait(false);
}
private async Task<PreparedSyncExport> PrepareExportAsync(WinoAccountSyncSelection selection)
{
var preferencesJson = selection.IncludePreferences
? _preferencesService.ExportPreferences()
: null;
var mailboxes = selection.IncludeAccounts
? (await _accountService.GetAccountsAsync().ConfigureAwait(false))
.OrderBy(a => a.Order)
.Select(MapMailbox)
.ToList()
: [];
return new PreparedSyncExport(
preferencesJson,
mailboxes,
new WinoAccountSyncExportResult
{
IncludedPreferences = selection.IncludePreferences,
IncludedAccounts = selection.IncludeAccounts,
ExportedMailboxCount = mailboxes.Count
});
}
private async Task<WinoAccountSyncImportResult> ImportDataAsync(
WinoAccountSyncSelection selection,
string? settingsJson,
List<UserMailboxSyncItemDto> mailboxes,
CancellationToken cancellationToken)
{ {
var result = new WinoAccountSyncImportResult var result = new WinoAccountSyncImportResult
{ {
@@ -73,30 +199,25 @@ public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService
IncludedAccounts = selection.IncludeAccounts IncludedAccounts = selection.IncludeAccounts
}; };
if (selection.IncludePreferences) if (selection.IncludePreferences && !string.IsNullOrWhiteSpace(settingsJson))
{ {
var settingsJson = await _profileService.GetSettingsAsync(cancellationToken).ConfigureAwait(false); var (appliedCount, failedCount) = _preferencesService.ImportPreferences(settingsJson);
if (!string.IsNullOrWhiteSpace(settingsJson)) result = new WinoAccountSyncImportResult
{ {
var (appliedCount, failedCount) = _preferencesService.ImportPreferences(settingsJson); IncludedPreferences = result.IncludedPreferences,
result = new WinoAccountSyncImportResult IncludedAccounts = result.IncludedAccounts,
{ HadRemotePreferences = true,
IncludedPreferences = result.IncludedPreferences, AppliedPreferenceCount = appliedCount,
IncludedAccounts = result.IncludedAccounts, FailedPreferenceCount = failedCount,
HadRemotePreferences = true, ImportedMailboxCount = result.ImportedMailboxCount,
AppliedPreferenceCount = appliedCount, SkippedDuplicateMailboxCount = result.SkippedDuplicateMailboxCount,
FailedPreferenceCount = failedCount, RemoteMailboxCount = result.RemoteMailboxCount
ImportedMailboxCount = result.ImportedMailboxCount, };
SkippedDuplicateMailboxCount = result.SkippedDuplicateMailboxCount,
RemoteMailboxCount = result.RemoteMailboxCount
};
}
} }
if (selection.IncludeAccounts) if (selection.IncludeAccounts)
{ {
var mailboxes = await _profileService.GetMailboxesAsync(cancellationToken).ConfigureAwait(false); var orderedMailboxes = mailboxes
var orderedMailboxes = mailboxes.Mailboxes
.OrderBy(a => a.SortOrder) .OrderBy(a => a.SortOrder)
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase) .ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
@@ -288,4 +409,14 @@ public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService
private static string CreateMailboxKey(string? address, int providerType) private static string CreateMailboxKey(string? address, int providerType)
=> $"{address?.Trim().ToLowerInvariant()}|{providerType}"; => $"{address?.Trim().ToLowerInvariant()}|{providerType}";
private static string TrimUtf8Bom(string jsonContent)
=> !string.IsNullOrEmpty(jsonContent) && jsonContent[0] == '\uFEFF'
? jsonContent[1..]
: jsonContent;
private sealed record PreparedSyncExport(
string? PreferencesJson,
List<UserMailboxSyncItemDto> Mailboxes,
WinoAccountSyncExportResult ExportResult);
} }