From c1bda75d9f7a38a2fb70a023c0222d160202add5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 23 Apr 2026 14:52:52 +0200 Subject: [PATCH] Migration plan v1 --- .../ILegacyLocalMigrationService.cs | 12 + .../Accounts/LegacyLocalMigrationPreview.cs | 46 + .../Accounts/LegacyLocalMigrationResult.cs | 25 + .../Translations/en_US/resources.json | 21 + .../LegacyLocalMigrationServiceTests.cs | 696 ++++++++++++++ .../AccountManagementViewModel.cs | 71 ++ .../Data/LegacyLocalMigrationFormatter.cs | 106 +++ Wino.Mail.ViewModels/MailAppShellViewModel.cs | 54 +- .../WelcomePageV2ViewModel.cs | 76 +- Wino.Mail.WinUI/App.xaml.cs | 11 +- .../ReleaseLocalAccountDataCleanupService.cs | 5 +- .../Views/Account/AccountManagementPage.xaml | 19 + Wino.Mail.WinUI/Views/ManageAccountsPage.xaml | 52 +- Wino.Mail.WinUI/Views/WelcomePageV2.xaml | 34 + .../UI/WelcomeImportCompletedMessage.cs | 2 +- Wino.Services/LegacyLocalMigrationService.cs | 881 ++++++++++++++++++ Wino.Services/ServicesContainerSetup.cs | 1 + 17 files changed, 2087 insertions(+), 25 deletions(-) create mode 100644 Wino.Core.Domain/Interfaces/ILegacyLocalMigrationService.cs create mode 100644 Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationPreview.cs create mode 100644 Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationResult.cs create mode 100644 Wino.Core.Tests/Services/LegacyLocalMigrationServiceTests.cs create mode 100644 Wino.Mail.ViewModels/Data/LegacyLocalMigrationFormatter.cs create mode 100644 Wino.Services/LegacyLocalMigrationService.cs diff --git a/Wino.Core.Domain/Interfaces/ILegacyLocalMigrationService.cs b/Wino.Core.Domain/Interfaces/ILegacyLocalMigrationService.cs new file mode 100644 index 00000000..342e145e --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ILegacyLocalMigrationService.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Core.Domain.Interfaces; + +public interface ILegacyLocalMigrationService +{ + Task DetectAsync(CancellationToken cancellationToken = default); + Task ImportAsync(CancellationToken cancellationToken = default); + void MarkPromptDeferred(); +} diff --git a/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationPreview.cs b/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationPreview.cs new file mode 100644 index 00000000..b445acba --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationPreview.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class LegacyLocalMigrationPreview +{ + public string SourceDatabasePath { get; init; } = string.Empty; + public bool LegacyDatabaseExists { get; init; } + public bool HasCompletedMigration { get; init; } + public bool IsPromptDeferred { get; init; } + public bool ShouldPrompt { get; init; } + public int LegacyAccountCount { get; init; } + public int ImportableAccountCount { get; init; } + public int DuplicateAccountCount { get; init; } + public int SkippedAccountCount { get; init; } + public int ImportableMergedInboxCount { get; init; } + public int SkippedMergedInboxCount { get; init; } + public IReadOnlyList ProviderCounts { get; init; } = []; + public IReadOnlyList Accounts { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + + public bool HasImportableData => LegacyDatabaseExists && ImportableAccountCount > 0; +} + +public sealed class LegacyLocalMigrationProviderCount +{ + public MailProviderType ProviderType { get; init; } + public int TotalAccountCount { get; init; } + public int ImportableAccountCount { get; init; } + public int DuplicateAccountCount { get; init; } +} + +public sealed class LegacyLocalMigrationAccountPreview +{ + public Guid LegacyAccountId { get; init; } + public string Address { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public MailProviderType ProviderType { get; init; } + public SpecialImapProvider SpecialImapProvider { get; init; } + public int Order { get; init; } + public bool CanImport { get; init; } + public bool IsDuplicate { get; init; } + public bool IsCalendarEnabled { get; init; } +} diff --git a/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationResult.cs b/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationResult.cs new file mode 100644 index 00000000..789ed978 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/LegacyLocalMigrationResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class LegacyLocalMigrationResult +{ + public LegacyLocalMigrationPreview Preview { get; init; } = new(); + public int ImportedAccountCount { get; init; } + public int SkippedDuplicateAccountCount { get; init; } + public int FailedAccountCount { get; init; } + public int ImportedMergedInboxCount { get; init; } + public int SkippedMergedInboxCount { get; init; } + public IReadOnlyList Failures { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + + public bool HasImportedData => ImportedAccountCount > 0 || ImportedMergedInboxCount > 0; +} + +public sealed class LegacyLocalMigrationFailure +{ + public string Address { get; init; } = string.Empty; + public MailProviderType ProviderType { get; init; } + public string Message { get; init; } = string.Empty; +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 247b881b..0f675342 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1368,6 +1368,26 @@ "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.", + "LegacyLocalMigration_WelcomeSectionTitle": "Import from your previous Wino version", + "LegacyLocalMigration_WelcomeSectionDescription": "Wino found account details in an older local database on this device. Import them now, then sign in again to finish reconnecting each account.", + "LegacyLocalMigration_PromptTitle": "Import accounts from your previous Wino version?", + "LegacyLocalMigration_ImportAction": "Import previous accounts", + "LegacyLocalMigration_PreviewSummary": "Found {0} account(s) ready to import: {1}.", + "LegacyLocalMigration_PreviewDuplicateSummary": "{0} account(s) already exist on this device and will be skipped.", + "LegacyLocalMigration_PreviewMergedSummary": "{0} merged inbox group(s) can be recreated after import.", + "LegacyLocalMigration_Provider_Outlook": "Outlook", + "LegacyLocalMigration_Provider_Gmail": "Gmail", + "LegacyLocalMigration_Provider_Imap": "IMAP", + "LegacyLocalMigration_Warning_OAuth": "Outlook and Gmail accounts will need you to sign in again to restore mail and calendar access.", + "LegacyLocalMigration_Warning_Imap": "IMAP and CalDAV passwords are never copied. Open the account settings afterward and enter them again.", + "LegacyLocalMigration_Warning_Merged": "Merged inboxes are recreated only when every member account imports successfully.", + "LegacyLocalMigration_Warning_SkippedAccounts": "Skipped {0} legacy account(s) because their provider or primary address could not be read safely.", + "LegacyLocalMigration_Warning_ReadFailed": "Wino found a previous local database, but it could not be read safely for migration.", + "LegacyLocalMigration_ImportAccountsSucceeded": "Imported {0} accounts from the previous local database.", + "LegacyLocalMigration_ImportMergedInboxesSucceeded": "Recreated {0} merged inbox group(s).", + "LegacyLocalMigration_ImportMergedInboxesSkipped": "Skipped {0} merged inbox group(s) because at least one member account could not be imported.", + "LegacyLocalMigration_ImportFailedAccounts": "{0} account(s) could not be imported.", + "LegacyLocalMigration_ImportEmpty": "There are no additional legacy accounts to import from this device.", "WelcomeWindow_SetupTitle": "Set up your account", "WelcomeWindow_SetupSubtitle": "Choose your email provider to get started", "WelcomeWindow_AddAccountButton": "Add account", @@ -1444,6 +1464,7 @@ "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...", + "LegacyLocalMigration_SettingsSectionTitle": "Import from a previous Wino version", "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", diff --git a/Wino.Core.Tests/Services/LegacyLocalMigrationServiceTests.cs b/Wino.Core.Tests/Services/LegacyLocalMigrationServiceTests.cs new file mode 100644 index 00000000..1bcf7c07 --- /dev/null +++ b/Wino.Core.Tests/Services/LegacyLocalMigrationServiceTests.cs @@ -0,0 +1,696 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using SQLite; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public sealed class LegacyLocalMigrationServiceTests +{ + [Fact] + public async Task DetectAsync_ReturnsPreviewCountsAndDuplicatesByProvider() + { + await using var context = await LegacyMigrationTestContext.CreateAsync(); + + await context.SeedCurrentAccountAsync( + "gmail@example.com", + MailProviderType.Gmail, + "Existing Gmail"); + + await context.InsertLegacyAccountAsync( + Guid.NewGuid(), + "outlook@example.com", + MailProviderType.Outlook, + order: 0, + name: "Outlook Legacy"); + + await context.InsertLegacyAccountAsync( + Guid.NewGuid(), + "gmail@example.com", + MailProviderType.Gmail, + order: 1, + name: "Duplicate Gmail"); + + var imapId = Guid.NewGuid(); + await context.InsertLegacyAccountAsync( + imapId, + "imap@example.com", + MailProviderType.IMAP4, + order: 2, + name: "Imported IMAP", + specialImapProvider: SpecialImapProvider.Yahoo); + + await context.InsertLegacyServerInformationAsync( + imapId, + address: "imap@example.com", + incomingServer: "imap.mail.yahoo.com", + incomingServerPort: "993", + incomingServerUsername: "imap@example.com", + outgoingServer: "smtp.mail.yahoo.com", + outgoingServerPort: "587", + outgoingServerUsername: "imap@example.com", + calendarSupportMode: ImapCalendarSupportMode.CalDav, + calDavServiceUrl: "https://caldav.calendar.yahoo.com/", + calDavUsername: "imap@example.com"); + + var preview = await context.Service.DetectAsync(); + + preview.LegacyDatabaseExists.Should().BeTrue(); + preview.ShouldPrompt.Should().BeTrue(); + preview.LegacyAccountCount.Should().Be(3); + preview.ImportableAccountCount.Should().Be(2); + preview.DuplicateAccountCount.Should().Be(1); + preview.Accounts.Select(a => a.Address).Should().ContainInOrder( + "outlook@example.com", + "gmail@example.com", + "imap@example.com"); + + preview.ProviderCounts.Should().ContainSingle(a => + a.ProviderType == MailProviderType.Outlook && + a.ImportableAccountCount == 1 && + a.DuplicateAccountCount == 0); + + preview.ProviderCounts.Should().ContainSingle(a => + a.ProviderType == MailProviderType.Gmail && + a.ImportableAccountCount == 0 && + a.DuplicateAccountCount == 1); + + preview.ProviderCounts.Should().ContainSingle(a => + a.ProviderType == MailProviderType.IMAP4 && + a.ImportableAccountCount == 1 && + a.DuplicateAccountCount == 0); + } + + [Fact] + public async Task ImportAsync_ImportsAccountsPreservesSafePreferencesAndRecreatesMergedInboxes() + { + await using var context = await LegacyMigrationTestContext.CreateAsync(); + + var mergedInboxId = Guid.NewGuid(); + await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Linked"); + + var legacyOutlookId = Guid.NewGuid(); + var legacyGmailId = Guid.NewGuid(); + var legacyImapId = Guid.NewGuid(); + var legacySignatureId = Guid.NewGuid(); + + await context.InsertLegacyAccountAsync( + legacyOutlookId, + "outlook@example.com", + MailProviderType.Outlook, + order: 0, + name: "Outlook Legacy", + senderName: "Outlook Sender", + mergedInboxId: mergedInboxId); + + await context.InsertLegacyPreferencesAsync( + legacyOutlookId, + isNotificationsEnabled: false, + isTaskbarBadgeEnabled: false, + shouldAppendMessagesToSentFolder: true, + isFocusedInboxEnabled: false, + signatureIdForNewMessages: legacySignatureId, + signatureIdForFollowingMessages: legacySignatureId); + + await context.InsertLegacyAccountAsync( + legacyGmailId, + "gmail@example.com", + MailProviderType.Gmail, + order: 1, + name: "Gmail Legacy", + senderName: "Gmail Sender", + mergedInboxId: mergedInboxId); + + await context.InsertLegacyAccountAsync( + legacyImapId, + "imap@example.com", + MailProviderType.IMAP4, + order: 2, + name: "iCloud Legacy", + senderName: "iCloud Sender", + specialImapProvider: SpecialImapProvider.iCloud); + + await context.InsertLegacyServerInformationAsync( + legacyImapId, + address: "imap@example.com", + incomingServer: "imap.mail.me.com", + incomingServerPort: "993", + incomingServerUsername: "imap-user", + outgoingServer: "smtp.mail.me.com", + outgoingServerPort: "587", + outgoingServerUsername: "smtp-user", + calendarSupportMode: ImapCalendarSupportMode.CalDav, + calDavServiceUrl: "https://caldav.icloud.com/", + calDavUsername: "imap@example.com", + maxConcurrentClients: 7); + + var result = await context.Service.ImportAsync(); + + result.ImportedAccountCount.Should().Be(3); + result.SkippedDuplicateAccountCount.Should().Be(0); + result.FailedAccountCount.Should().Be(0); + result.ImportedMergedInboxCount.Should().Be(1); + result.SkippedMergedInboxCount.Should().Be(0); + + var accounts = await context.AccountService.GetAccountsAsync(); + accounts.Should().HaveCount(3); + accounts.Select(a => a.Address).Should().ContainInOrder( + "outlook@example.com", + "gmail@example.com", + "imap@example.com"); + + var outlookAccount = accounts.Single(a => a.Address == "outlook@example.com"); + outlookAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials); + outlookAccount.IsMailAccessGranted.Should().BeTrue(); + outlookAccount.IsCalendarAccessGranted.Should().BeTrue(); + outlookAccount.Preferences.IsNotificationsEnabled.Should().BeFalse(); + outlookAccount.Preferences.IsTaskbarBadgeEnabled.Should().BeFalse(); + outlookAccount.Preferences.ShouldAppendMessagesToSentFolder.Should().BeTrue(); + outlookAccount.Preferences.IsFocusedInboxEnabled.Should().BeFalse(); + outlookAccount.Preferences.SignatureIdForNewMessages.Should().NotBe(legacySignatureId); + outlookAccount.Preferences.SignatureIdForFollowingMessages.Should().NotBe(legacySignatureId); + + var gmailAliases = await context.AccountService.GetAccountAliasesAsync(accounts.Single(a => a.Address == "gmail@example.com").Id); + gmailAliases.Should().ContainSingle(a => + a.IsRootAlias && + a.IsPrimary && + a.AliasAddress == "gmail@example.com"); + + var imapAccount = accounts.Single(a => a.Address == "imap@example.com"); + imapAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials); + imapAccount.IsCalendarAccessGranted.Should().BeTrue(); + + var serverInformation = await context.AccountService.GetAccountCustomServerInformationAsync(imapAccount.Id); + serverInformation.Should().NotBeNull(); + serverInformation.IncomingServer.Should().Be("imap.mail.me.com"); + serverInformation.OutgoingServer.Should().Be("smtp.mail.me.com"); + serverInformation.IncomingServerPassword.Should().BeEmpty(); + serverInformation.OutgoingServerPassword.Should().BeEmpty(); + serverInformation.CalDavPassword.Should().BeEmpty(); + serverInformation.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/"); + serverInformation.CalDavUsername.Should().Be("imap@example.com"); + serverInformation.MaxConcurrentClients.Should().Be(7); + + var mergedInboxIds = accounts + .Where(a => a.Address is "outlook@example.com" or "gmail@example.com") + .Select(a => a.MergedInboxId) + .Distinct() + .ToList(); + + mergedInboxIds.Should().ContainSingle(); + mergedInboxIds[0].Should().NotBeNull(); + accounts.Single(a => a.Address == "outlook@example.com").MergedInbox.Name.Should().Be("Legacy Linked"); + } + + [Fact] + public async Task ImportAsync_WithMissingLegacySchemaColumns_DefaultsSafelyAndSkipsIncompleteMergedInboxes() + { + await using var context = await LegacyMigrationTestContext.CreateAsync(new LegacySchemaOptions( + IncludeCalDavColumns: false, + IncludeCalendarSupportMode: false, + IncludeTaskbarBadgeColumn: false, + IncludeFocusedInboxColumn: false)); + + await context.SeedCurrentAccountAsync( + "duplicate@icloud.com", + MailProviderType.IMAP4, + "Existing iCloud", + specialImapProvider: SpecialImapProvider.iCloud); + + var mergedInboxId = Guid.NewGuid(); + var duplicateLegacyAccountId = Guid.NewGuid(); + var importableLegacyAccountId = Guid.NewGuid(); + + await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Incomplete"); + + await context.InsertLegacyAccountAsync( + duplicateLegacyAccountId, + "duplicate@icloud.com", + MailProviderType.IMAP4, + order: 0, + name: "Duplicate iCloud", + specialImapProvider: SpecialImapProvider.iCloud, + mergedInboxId: mergedInboxId); + + await context.InsertLegacyAccountAsync( + importableLegacyAccountId, + "new@icloud.com", + MailProviderType.IMAP4, + order: 1, + name: "Importable iCloud", + specialImapProvider: SpecialImapProvider.iCloud, + mergedInboxId: mergedInboxId); + + await context.InsertLegacyServerInformationAsync( + duplicateLegacyAccountId, + address: "duplicate@icloud.com", + incomingServer: "imap.mail.me.com", + incomingServerPort: "993", + incomingServerUsername: "duplicate", + outgoingServer: "smtp.mail.me.com", + outgoingServerPort: "587", + outgoingServerUsername: "duplicate"); + + await context.InsertLegacyServerInformationAsync( + importableLegacyAccountId, + address: "new@icloud.com", + incomingServer: "imap.mail.me.com", + incomingServerPort: "993", + incomingServerUsername: "new", + outgoingServer: "smtp.mail.me.com", + outgoingServerPort: "587", + outgoingServerUsername: "new"); + + var result = await context.Service.ImportAsync(); + + result.ImportedAccountCount.Should().Be(1); + result.SkippedDuplicateAccountCount.Should().Be(1); + result.ImportedMergedInboxCount.Should().Be(0); + result.SkippedMergedInboxCount.Should().Be(1); + + var importedAccount = (await context.AccountService.GetAccountsAsync()) + .Single(a => a.Address == "new@icloud.com"); + + importedAccount.IsCalendarAccessGranted.Should().BeFalse(); + importedAccount.MergedInboxId.Should().BeNull(); + importedAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials); + + var importedServerInfo = await context.AccountService.GetAccountCustomServerInformationAsync(importedAccount.Id); + importedServerInfo.Should().NotBeNull(); + importedServerInfo.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/"); + importedServerInfo.CalDavUsername.Should().Be("new@icloud.com"); + importedServerInfo.CalDavPassword.Should().BeEmpty(); + importedServerInfo.CalendarSupportMode.Should().Be(ImapCalendarSupportMode.Disabled); + importedServerInfo.IncomingServerPassword.Should().BeEmpty(); + importedServerInfo.OutgoingServerPassword.Should().BeEmpty(); + } + + private sealed class LegacyMigrationTestContext : IAsyncDisposable + { + private readonly SQLiteAsyncConnection _legacyConnection; + private readonly InMemoryDatabaseService _databaseService; + private readonly string _legacyFolderPath; + + public LegacyLocalMigrationService Service { get; } + public AccountService AccountService { get; } + + private LegacyMigrationTestContext(string legacyFolderPath, + SQLiteAsyncConnection legacyConnection, + InMemoryDatabaseService databaseService, + AccountService accountService, + LegacyLocalMigrationService service) + { + _legacyFolderPath = legacyFolderPath; + _legacyConnection = legacyConnection; + _databaseService = databaseService; + AccountService = accountService; + Service = service; + } + + public static async Task CreateAsync(LegacySchemaOptions? schemaOptions = null) + { + var databaseService = new InMemoryDatabaseService(); + await databaseService.InitializeAsync(); + + var preferencesService = new Mock(); + preferencesService.SetupProperty(a => a.StartupEntityId); + + var accountService = CreateAccountService(databaseService, preferencesService.Object); + + var legacyFolderPath = Path.Combine(Path.GetTempPath(), $"legacy-migration-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(legacyFolderPath); + + var legacyDatabasePath = Path.Combine(legacyFolderPath, "Wino180.db"); + var legacyConnection = new SQLiteAsyncConnection(legacyDatabasePath); + + await CreateLegacySchemaAsync(legacyConnection, schemaOptions ?? LegacySchemaOptions.Default); + + var applicationConfiguration = new ApplicationConfiguration + { + ApplicationDataFolderPath = legacyFolderPath, + ApplicationTempFolderPath = legacyFolderPath, + PublisherSharedFolderPath = legacyFolderPath + }; + + var service = new LegacyLocalMigrationService( + applicationConfiguration, + new InMemoryConfigurationService(), + databaseService, + accountService, + new SpecialImapProviderConfigResolver()); + + return new LegacyMigrationTestContext( + legacyFolderPath, + legacyConnection, + databaseService, + accountService, + service); + } + + public async Task SeedCurrentAccountAsync(string address, + MailProviderType providerType, + string name, + SpecialImapProvider specialImapProvider = SpecialImapProvider.None) + { + var accountId = Guid.NewGuid(); + CustomServerInformation? serverInformation = null; + + if (providerType == MailProviderType.IMAP4) + { + serverInformation = new CustomServerInformation + { + Id = Guid.NewGuid(), + AccountId = accountId, + Address = address, + IncomingServer = "imap.current.test", + IncomingServerPort = "993", + IncomingServerUsername = address, + IncomingServerPassword = "secret", + IncomingServerSocketOption = ImapConnectionSecurity.Auto, + IncomingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword, + OutgoingServer = "smtp.current.test", + OutgoingServerPort = "587", + OutgoingServerUsername = address, + OutgoingServerPassword = "secret", + OutgoingServerSocketOption = ImapConnectionSecurity.Auto, + OutgoingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword, + CalDavServiceUrl = string.Empty, + CalDavUsername = string.Empty, + CalDavPassword = string.Empty, + CalendarSupportMode = ImapCalendarSupportMode.Disabled, + MaxConcurrentClients = 5 + }; + } + + await AccountService.CreateAccountAsync( + new MailAccount + { + Id = accountId, + Name = name, + SenderName = name, + Address = address, + ProviderType = providerType, + SpecialImapProvider = specialImapProvider, + IsMailAccessGranted = true, + IsCalendarAccessGranted = providerType is MailProviderType.Outlook or MailProviderType.Gmail + }, + serverInformation); + } + + public Task InsertLegacyAccountAsync(Guid accountId, + string address, + MailProviderType providerType, + int order, + string name, + string? senderName = null, + SpecialImapProvider specialImapProvider = SpecialImapProvider.None, + Guid? mergedInboxId = null) + => InsertRowAsync( + _legacyConnection, + "MailAccount", + ("Id", accountId), + ("Address", address), + ("Name", name), + ("SenderName", senderName ?? name), + ("ProviderType", (int)providerType), + ("SpecialImapProvider", (int)specialImapProvider), + ("Order", order), + ("AccountColorHex", "#123456"), + ("MergedInboxId", mergedInboxId)); + + public Task InsertLegacyPreferencesAsync(Guid accountId, + bool? isNotificationsEnabled = null, + bool? isTaskbarBadgeEnabled = null, + bool? shouldAppendMessagesToSentFolder = null, + bool? isFocusedInboxEnabled = null, + Guid? signatureIdForNewMessages = null, + Guid? signatureIdForFollowingMessages = null) + { + var values = new List<(string Column, object? Value)> + { + ("Id", Guid.NewGuid()), + ("AccountId", accountId) + }; + + if (isNotificationsEnabled.HasValue) + values.Add(("IsNotificationsEnabled", isNotificationsEnabled.Value)); + + if (isTaskbarBadgeEnabled.HasValue) + values.Add(("IsTaskbarBadgeEnabled", isTaskbarBadgeEnabled.Value)); + + if (shouldAppendMessagesToSentFolder.HasValue) + values.Add(("ShouldAppendMessagesToSentFolder", shouldAppendMessagesToSentFolder.Value)); + + if (isFocusedInboxEnabled.HasValue) + values.Add(("IsFocusedInboxEnabled", isFocusedInboxEnabled.Value)); + + if (signatureIdForNewMessages.HasValue) + values.Add(("SignatureIdForNewMessages", signatureIdForNewMessages.Value)); + + if (signatureIdForFollowingMessages.HasValue) + values.Add(("SignatureIdForFollowingMessages", signatureIdForFollowingMessages.Value)); + + return InsertRowAsync(_legacyConnection, "MailAccountPreferences", values.ToArray()); + } + + public Task InsertLegacyServerInformationAsync(Guid accountId, + string address, + string incomingServer, + string incomingServerPort, + string incomingServerUsername, + string outgoingServer, + string outgoingServerPort, + string outgoingServerUsername, + ImapCalendarSupportMode? calendarSupportMode = null, + string? calDavServiceUrl = null, + string? calDavUsername = null, + int? maxConcurrentClients = null) + { + var values = new List<(string Column, object? Value)> + { + ("Id", Guid.NewGuid()), + ("AccountId", accountId), + ("Address", address), + ("IncomingServer", incomingServer), + ("IncomingServerPort", incomingServerPort), + ("IncomingServerUsername", incomingServerUsername), + ("IncomingServerSocketOption", (int)ImapConnectionSecurity.Auto), + ("IncomingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword), + ("OutgoingServer", outgoingServer), + ("OutgoingServerPort", outgoingServerPort), + ("OutgoingServerUsername", outgoingServerUsername), + ("OutgoingServerSocketOption", (int)ImapConnectionSecurity.Auto), + ("OutgoingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword), + ("ProxyServer", "proxy.example.com"), + ("ProxyServerPort", "8080"), + ("MaxConcurrentClients", maxConcurrentClients ?? 5) + }; + + if (calendarSupportMode.HasValue) + values.Add(("CalendarSupportMode", (int)calendarSupportMode.Value)); + + if (!string.IsNullOrWhiteSpace(calDavServiceUrl)) + values.Add(("CalDavServiceUrl", calDavServiceUrl)); + + if (!string.IsNullOrWhiteSpace(calDavUsername)) + values.Add(("CalDavUsername", calDavUsername)); + + return InsertRowAsync(_legacyConnection, "CustomServerInformation", values.ToArray()); + } + + public Task InsertLegacyMergedInboxAsync(Guid mergedInboxId, string name) + => InsertRowAsync( + _legacyConnection, + "MergedInbox", + ("Id", mergedInboxId), + ("Name", name)); + + public async ValueTask DisposeAsync() + { + await _legacyConnection.CloseAsync(); + + if (Directory.Exists(_legacyFolderPath)) + { + Directory.Delete(_legacyFolderPath, recursive: true); + } + + await _databaseService.DisposeAsync(); + } + } + + private sealed class InMemoryConfigurationService : IConfigurationService + { + private readonly Dictionary _localValues = new(StringComparer.Ordinal); + private readonly Dictionary _roamingValues = new(StringComparer.Ordinal); + + public bool Contains(string key) => _localValues.ContainsKey(key); + + public T Get(string key, T defaultValue = default!) + => TryGetValue(_localValues, key, defaultValue); + + public T GetRoaming(string key, T defaultValue = default!) + => TryGetValue(_roamingValues, key, defaultValue); + + public void Set(string key, object value) + => _localValues[key] = value?.ToString(); + + public void SetRoaming(string key, object value) + => _roamingValues[key] = value?.ToString(); + + private static T TryGetValue(Dictionary values, string key, T defaultValue) + { + if (!values.TryGetValue(key, out var stringValue) || string.IsNullOrWhiteSpace(stringValue)) + { + return defaultValue; + } + + if (typeof(T).IsEnum) + { + return (T)Enum.Parse(typeof(T), stringValue); + } + + if ((typeof(T) == typeof(Guid) || typeof(T) == typeof(Guid?)) && Guid.TryParse(stringValue, out var guid)) + { + return (T)(object)guid; + } + + return (T)Convert.ChangeType(stringValue, typeof(T)); + } + } + + private sealed record LegacySchemaOptions( + bool IncludeCalDavColumns, + bool IncludeCalendarSupportMode, + bool IncludeTaskbarBadgeColumn, + bool IncludeFocusedInboxColumn) + { + public static LegacySchemaOptions Default => new(true, true, true, true); + } + + private static async Task CreateLegacySchemaAsync(SQLiteAsyncConnection connection, LegacySchemaOptions options) + { + await connection.ExecuteAsync(""" +CREATE TABLE MailAccount ( + Id TEXT PRIMARY KEY, + Address TEXT NULL, + Name TEXT NULL, + SenderName TEXT NULL, + ProviderType INTEGER NOT NULL, + SpecialImapProvider INTEGER NOT NULL DEFAULT 0, + [Order] INTEGER NOT NULL DEFAULT 0, + AccountColorHex TEXT NULL, + MergedInboxId TEXT NULL +) +"""); + + var preferenceColumns = new List + { + "Id TEXT PRIMARY KEY", + "AccountId TEXT NOT NULL", + "IsNotificationsEnabled INTEGER NULL", + "ShouldAppendMessagesToSentFolder INTEGER NULL", + "SignatureIdForNewMessages TEXT NULL", + "SignatureIdForFollowingMessages TEXT NULL" + }; + + if (options.IncludeTaskbarBadgeColumn) + preferenceColumns.Add("IsTaskbarBadgeEnabled INTEGER NULL"); + + if (options.IncludeFocusedInboxColumn) + preferenceColumns.Add("IsFocusedInboxEnabled INTEGER NULL"); + + await connection.ExecuteAsync($"CREATE TABLE MailAccountPreferences ({string.Join(", ", preferenceColumns)})"); + + var serverColumns = new List + { + "Id TEXT PRIMARY KEY", + "AccountId TEXT NOT NULL", + "Address TEXT NULL", + "IncomingServer TEXT NULL", + "IncomingServerPort TEXT NULL", + "IncomingServerUsername TEXT NULL", + "IncomingServerSocketOption INTEGER NULL", + "IncomingAuthenticationMethod INTEGER NULL", + "OutgoingServer TEXT NULL", + "OutgoingServerPort TEXT NULL", + "OutgoingServerUsername TEXT NULL", + "OutgoingServerSocketOption INTEGER NULL", + "OutgoingAuthenticationMethod INTEGER NULL", + "ProxyServer TEXT NULL", + "ProxyServerPort TEXT NULL", + "MaxConcurrentClients INTEGER NULL" + }; + + if (options.IncludeCalDavColumns) + { + serverColumns.Add("CalDavServiceUrl TEXT NULL"); + serverColumns.Add("CalDavUsername TEXT NULL"); + } + + if (options.IncludeCalendarSupportMode) + { + serverColumns.Add("CalendarSupportMode INTEGER NULL"); + } + + await connection.ExecuteAsync($"CREATE TABLE CustomServerInformation ({string.Join(", ", serverColumns)})"); + await connection.ExecuteAsync(""" +CREATE TABLE MergedInbox ( + Id TEXT PRIMARY KEY, + Name TEXT NULL +) +"""); + } + + private static Task InsertRowAsync(SQLiteAsyncConnection connection, string tableName, params (string Column, object? Value)[] values) + { + var columns = string.Join(", ", values.Select(a => $"[{a.Column}]")); + var placeholders = string.Join(", ", values.Select(_ => "?")); + var arguments = values.Select(a => ConvertLegacyValue(a.Value)).ToArray(); + + return connection.ExecuteAsync($"INSERT INTO [{tableName}] ({columns}) VALUES ({placeholders})", arguments); + } + + private static object? ConvertLegacyValue(object? value) + { + return value switch + { + null => null, + bool boolValue => boolValue ? 1 : 0, + Guid guidValue => guidValue.ToString(), + _ => value + }; + } + + private static AccountService CreateAccountService(InMemoryDatabaseService databaseService, IPreferencesService preferencesService) + { + var signatureService = new Mock(); + signatureService + .Setup(a => a.CreateDefaultSignatureAsync(It.IsAny())) + .ReturnsAsync((Guid accountId) => new AccountSignature + { + Id = Guid.NewGuid(), + MailAccountId = accountId, + Name = "Default", + HtmlBody = string.Empty + }); + + return new AccountService( + databaseService, + signatureService.Object, + Mock.Of(), + Mock.Of(), + preferencesService, + Mock.Of()); + } +} diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index 757cc2ab..36fec327 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -17,6 +17,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; @@ -35,6 +36,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel private static readonly UTF8Encoding Utf8WithoutBom = new(false); private readonly IWinoAccountDataSyncService _syncService; + private readonly ILegacyLocalMigrationService _legacyLocalMigrationService; private readonly IWinoLogger _winoLogger; private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; private readonly ICalDavClient _calDavClient; @@ -48,6 +50,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel IStoreManagementService storeManagementService, IWinoAccountProfileService winoAccountProfileService, IWinoAccountDataSyncService syncService, + ILegacyLocalMigrationService legacyLocalMigrationService, IWinoLogger winoLogger, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ICalDavClient calDavClient, @@ -56,6 +59,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel { MailDialogService = dialogService; _syncService = syncService; + _legacyLocalMigrationService = legacyLocalMigrationService; _winoLogger = winoLogger; _specialImapProviderConfigResolver = specialImapProviderConfigResolver; _calDavClient = calDavClient; @@ -64,8 +68,26 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))] [NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))] + [NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))] public partial bool IsDataTransferInProgress { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasLegacyImportAvailable))] + [NotifyPropertyChangedFor(nameof(HasLegacyImportWarnings))] + [NotifyPropertyChangedFor(nameof(LegacyMigrationSummary))] + [NotifyPropertyChangedFor(nameof(LegacyMigrationWarningSummary))] + [NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))] + public partial LegacyLocalMigrationPreview LegacyMigrationPreview { get; set; } + + public bool HasLegacyImportAvailable => LegacyMigrationPreview?.HasImportableData == true; + public bool HasLegacyImportWarnings => !string.IsNullOrWhiteSpace(LegacyMigrationWarningSummary); + public string LegacyMigrationSummary => HasLegacyImportAvailable + ? LegacyLocalMigrationFormatter.BuildPreviewSummary(LegacyMigrationPreview) + : string.Empty; + public string LegacyMigrationWarningSummary => HasLegacyImportAvailable + ? LegacyLocalMigrationFormatter.BuildWarningSummary(LegacyMigrationPreview) + : string.Empty; + [RelayCommand] private async Task CreateMergedAccountAsync() { @@ -314,7 +336,42 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel } } + [RelayCommand(CanExecute = nameof(CanImportLegacyDatabase))] + private async Task ImportLegacyDatabaseAsync() + { + try + { + await ExecuteUIThread(() => IsDataTransferInProgress = true); + + var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false); + + await InitializeAccountsAsync().ConfigureAwait(false); + await RefreshLegacyMigrationPreviewAsync().ConfigureAwait(false); + + var messageType = result.FailedAccountCount > 0 + ? InfoBarMessageType.Warning + : InfoBarMessageType.Success; + + DialogService.InfoBarMessage( + result.FailedAccountCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info, + LegacyLocalMigrationFormatter.BuildImportMessage(result), + messageType); + } + catch (Exception ex) + { + DialogService.InfoBarMessage( + Translator.GeneralTitle_Error, + ex.Message, + InfoBarMessageType.Error); + } + finally + { + await ExecuteUIThread(() => IsDataTransferInProgress = false); + } + } + private bool CanTransferLocalData() => !IsDataTransferInProgress; + private bool CanImportLegacyDatabase() => !IsDataTransferInProgress && HasLegacyImportAvailable; public override void OnNavigatedFrom(NavigationMode mode, object parameters) { @@ -350,6 +407,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel Accounts.CollectionChanged += AccountCollectionChanged; await InitializeAccountsAsync(); + await RefreshLegacyMigrationPreviewAsync(); PropertyChanged -= PagePropertyChanged; PropertyChanged += PagePropertyChanged; @@ -403,6 +461,19 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel await ManageStorePurchasesAsync().ConfigureAwait(false); } + private async Task RefreshLegacyMigrationPreviewAsync() + { + try + { + var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false); + await ExecuteUIThread(() => LegacyMigrationPreview = preview); + } + catch (Exception) + { + await ExecuteUIThread(() => LegacyMigrationPreview = null); + } + } + private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result) { var parts = new Collection(); diff --git a/Wino.Mail.ViewModels/Data/LegacyLocalMigrationFormatter.cs b/Wino.Mail.ViewModels/Data/LegacyLocalMigrationFormatter.cs new file mode 100644 index 00000000..4f56fc6c --- /dev/null +++ b/Wino.Mail.ViewModels/Data/LegacyLocalMigrationFormatter.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Mail.ViewModels.Data; + +internal static class LegacyLocalMigrationFormatter +{ + public static string BuildPreviewSummary(LegacyLocalMigrationPreview preview) + { + if (!preview.HasImportableData) + { + return Translator.LegacyLocalMigration_ImportEmpty; + } + + var providerSummary = string.Join(", ", preview.ProviderCounts + .Where(a => a.ImportableAccountCount > 0) + .Select(a => $"{a.ImportableAccountCount} {GetProviderName(a.ProviderType)}")); + + var parts = new List + { + string.Format(Translator.LegacyLocalMigration_PreviewSummary, preview.ImportableAccountCount, providerSummary) + }; + + if (preview.DuplicateAccountCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewDuplicateSummary, preview.DuplicateAccountCount)); + } + + if (preview.ImportableMergedInboxCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewMergedSummary, preview.ImportableMergedInboxCount)); + } + + return string.Join(" ", parts.Where(a => !string.IsNullOrWhiteSpace(a))); + } + + public static string BuildWarningSummary(LegacyLocalMigrationPreview preview) + => string.Join(Environment.NewLine, preview.Warnings.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.Ordinal)); + + public static string BuildImportMessage(LegacyLocalMigrationResult result) + { + var parts = new List(); + + if (result.ImportedAccountCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_ImportAccountsSucceeded, result.ImportedAccountCount)); + } + + if (result.SkippedDuplicateAccountCount > 0) + { + parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateAccountCount)); + } + + if (result.ImportedMergedInboxCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSucceeded, result.ImportedMergedInboxCount)); + } + + if (result.SkippedMergedInboxCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSkipped, result.SkippedMergedInboxCount)); + } + + if (result.FailedAccountCount > 0) + { + parts.Add(string.Format(Translator.LegacyLocalMigration_ImportFailedAccounts, result.FailedAccountCount)); + } + + if (parts.Count == 0) + { + parts.Add(Translator.LegacyLocalMigration_ImportEmpty); + } + + if (result.ImportedAccountCount > 0) + { + parts.Add(Translator.WinoAccount_Management_ImportReloginReminder); + } + + return string.Join(" ", parts); + } + + public static string BuildPromptMessage(LegacyLocalMigrationPreview preview) + { + var summary = BuildPreviewSummary(preview); + var warnings = BuildWarningSummary(preview); + + return string.IsNullOrWhiteSpace(warnings) + ? summary + : $"{summary}{Environment.NewLine}{Environment.NewLine}{warnings}"; + } + + private static string GetProviderName(MailProviderType providerType) + { + return providerType switch + { + MailProviderType.Outlook => Translator.LegacyLocalMigration_Provider_Outlook, + MailProviderType.Gmail => Translator.LegacyLocalMigration_Provider_Gmail, + MailProviderType.IMAP4 => Translator.LegacyLocalMigration_Provider_Imap, + _ => providerType.ToString() + }; + } +} diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 4c77309e..9aa891c3 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -90,10 +90,12 @@ public partial class MailAppShellViewModel : MailBaseViewModel, private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService; private readonly IStoreUpdateService _storeUpdateService; private readonly IShareActivationService _shareActivationService; + private readonly ILegacyLocalMigrationService _legacyLocalMigrationService; private readonly INativeAppService _nativeAppService; private readonly IMailService _mailService; private bool _hasRegisteredPersistentRecipients; + private bool _hasHandledLegacyMigrationPrompt; private readonly SemaphoreSlim _menuRefreshSemaphore = new(1, 1); private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1); @@ -117,7 +119,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IStartupBehaviorService startupBehaviorService, IWebView2RuntimeValidatorService webView2RuntimeValidatorService, IStoreUpdateService storeUpdateService, - IShareActivationService shareActivationService) + IShareActivationService shareActivationService, + ILegacyLocalMigrationService legacyLocalMigrationService) { StatePersistenceService = statePersistanceService; @@ -141,6 +144,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, _webView2RuntimeValidatorService = webView2RuntimeValidatorService; _storeUpdateService = storeUpdateService; _shareActivationService = shareActivationService; + _legacyLocalMigrationService = legacyLocalMigrationService; } protected override void OnDispatcherAssigned() @@ -286,6 +290,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, await ProcessLaunchOptionsAsync(); await HandlePendingShareRequestAsync(); await ValidateWebView2RuntimeAsync(); + await PromptLegacyMigrationIfNeededAsync(shouldRunStartupFlows); if (shouldRunStartupFlows && !Debugger.IsAttached) { @@ -298,6 +303,53 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } } + private async Task PromptLegacyMigrationIfNeededAsync(bool shouldRunStartupFlows) + { + if (!shouldRunStartupFlows || _hasHandledLegacyMigrationPrompt) + { + return; + } + + _hasHandledLegacyMigrationPrompt = true; + + var currentAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false); + if (!currentAccounts.Any()) + { + return; + } + + var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false); + if (!preview.ShouldPrompt || !preview.HasImportableData) + { + return; + } + + var shouldImport = await _dialogService.ShowConfirmationDialogAsync( + LegacyLocalMigrationFormatter.BuildPromptMessage(preview), + Translator.LegacyLocalMigration_PromptTitle, + Translator.LegacyLocalMigration_ImportAction); + + if (!shouldImport) + { + _legacyLocalMigrationService.MarkPromptDeferred(); + return; + } + + var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false); + + await RecreateMenuItemsAsync().ConfigureAwait(false); + await RestoreSelectedAccountAfterMenuRefreshAsync(false).ConfigureAwait(false); + + var messageType = result.FailedAccountCount > 0 + ? InfoBarMessageType.Warning + : InfoBarMessageType.Success; + + _dialogService.InfoBarMessage( + result.FailedAccountCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info, + LegacyLocalMigrationFormatter.BuildImportMessage(result), + messageType); + } + private async Task ValidateWebView2RuntimeAsync() { var isRuntimeAvailable = await _webView2RuntimeValidatorService.IsRuntimeAvailableAsync(); diff --git a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs index a3021e8d..23a18564 100644 --- a/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs +++ b/Wino.Mail.ViewModels/WelcomePageV2ViewModel.cs @@ -24,6 +24,7 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel private readonly IUpdateManager _updateManager; private readonly IMailDialogService _dialogService; private readonly IWinoAccountDataSyncService _syncService; + private readonly ILegacyLocalMigrationService _legacyLocalMigrationService; [ObservableProperty] public partial List UpdateSections { get; set; } = []; @@ -32,21 +33,40 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel [NotifyCanExecuteChangedFor(nameof(GetStartedCommand))] [NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))] [NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))] + [NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))] public partial bool IsImportInProgress { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasImportStatus))] public partial string ImportStatusMessage { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasLegacyImportPreview))] + [NotifyPropertyChangedFor(nameof(HasLegacyImportWarnings))] + [NotifyPropertyChangedFor(nameof(LegacyImportSummary))] + [NotifyPropertyChangedFor(nameof(LegacyImportWarnings))] + [NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))] + public partial LegacyLocalMigrationPreview LegacyMigrationPreview { get; set; } + public bool HasImportStatus => !string.IsNullOrWhiteSpace(ImportStatusMessage); + public bool HasLegacyImportPreview => LegacyMigrationPreview?.HasImportableData == true; + public bool HasLegacyImportWarnings => !string.IsNullOrWhiteSpace(LegacyImportWarnings); + public string LegacyImportSummary => HasLegacyImportPreview + ? LegacyLocalMigrationFormatter.BuildPreviewSummary(LegacyMigrationPreview) + : string.Empty; + public string LegacyImportWarnings => HasLegacyImportPreview + ? LegacyLocalMigrationFormatter.BuildWarningSummary(LegacyMigrationPreview) + : string.Empty; public WelcomePageV2ViewModel(IUpdateManager updateManager, IMailDialogService dialogService, - IWinoAccountDataSyncService syncService) + IWinoAccountDataSyncService syncService, + ILegacyLocalMigrationService legacyLocalMigrationService) { _updateManager = updateManager; _dialogService = dialogService; _syncService = syncService; + _legacyLocalMigrationService = legacyLocalMigrationService; } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -62,6 +82,15 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel { UpdateSections = []; } + + try + { + LegacyMigrationPreview = await _legacyLocalMigrationService.DetectAsync(); + } + catch (Exception) + { + LegacyMigrationPreview = null; + } } [RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))] @@ -150,7 +179,52 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel } } + [RelayCommand(CanExecute = nameof(CanImportLegacyDatabase))] + private async Task ImportLegacyDatabaseAsync() + { + await ExecuteUIThread(() => ImportStatusMessage = string.Empty); + + try + { + await ExecuteUIThread(() => IsImportInProgress = true); + + var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false); + if (result.ImportedAccountCount > 0) + { + ReportUIChange(new WelcomeImportCompletedMessage( + result.ImportedAccountCount, + LegacyLocalMigrationFormatter.BuildImportMessage(result))); + return; + } + + await ExecuteUIThread(() => + { + ImportStatusMessage = LegacyLocalMigrationFormatter.BuildImportMessage(result); + LegacyMigrationPreview = result.Preview; + }); + } + catch (Exception ex) + { + await _dialogService.ShowMessageAsync(ex.Message, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error); + } + finally + { + try + { + var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false); + await ExecuteUIThread(() => LegacyMigrationPreview = preview); + } + catch (Exception) + { + // Keep the current preview if detection fails after import. + } + + await ExecuteUIThread(() => IsImportInProgress = false); + } + } + private bool CanOpenWelcomeActions() => !IsImportInProgress; + private bool CanImportLegacyDatabase() => !IsImportInProgress && HasLegacyImportPreview; private static string BuildInlineImportMessage(WinoAccountSyncImportResult result) { diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 177159fc..5e8d609a 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -479,8 +479,8 @@ public partial class App : WinoApplication, EnsureAppNotificationRegistration(); - await Services.GetRequiredService() - .RunIfNeededAsync(); + await Services.GetRequiredService() + .DetectAsync(); await InitializeServicesAsync(); @@ -490,6 +490,9 @@ public partial class App : WinoApplication, _hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any(); + await Services.GetRequiredService() + .RunIfNeededAsync(); + _activationInfrastructureInitialized = true; } finally @@ -1502,7 +1505,9 @@ public partial class App : WinoApplication, Services.GetRequiredService().InfoBarMessage( Translator.GeneralTitle_Info, - Translator.WinoAccount_Management_ImportReloginReminder, + string.IsNullOrWhiteSpace(message.CompletionMessage) + ? Translator.WinoAccount_Management_ImportReloginReminder + : message.CompletionMessage, InfoBarMessageType.Information); }); } diff --git a/Wino.Mail.WinUI/Services/ReleaseLocalAccountDataCleanupService.cs b/Wino.Mail.WinUI/Services/ReleaseLocalAccountDataCleanupService.cs index 44edc737..4eb85246 100644 --- a/Wino.Mail.WinUI/Services/ReleaseLocalAccountDataCleanupService.cs +++ b/Wino.Mail.WinUI/Services/ReleaseLocalAccountDataCleanupService.cs @@ -10,7 +10,6 @@ namespace Wino.Mail.WinUI.Services; public sealed class ReleaseLocalAccountDataCleanupService { private const string CleanupCompletedSettingKey = "ReleaseLocalAccountDataCleanup_v1_Completed"; - private const string LegacyDatabaseFileName = "Wino180.db"; private readonly IConfigurationService _configurationService; private readonly IApplicationConfiguration _applicationConfiguration; @@ -29,7 +28,6 @@ public sealed class ReleaseLocalAccountDataCleanupService return; var localFolderPath = _applicationConfiguration.ApplicationDataFolderPath; - var publisherPath = _applicationConfiguration.PublisherSharedFolderPath; if (string.IsNullOrWhiteSpace(localFolderPath) || !Directory.Exists(localFolderPath)) { @@ -41,8 +39,7 @@ public sealed class ReleaseLocalAccountDataCleanupService { Path.Combine(localFolderPath, "Mime"), Path.Combine(localFolderPath, "contacts"), - Path.Combine(localFolderPath, "CalendarAttachments"), - Path.Combine(publisherPath, LegacyDatabaseFileName) + Path.Combine(localFolderPath, "CalendarAttachments") }; foreach (var targetPath in cleanupTargets) diff --git a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml index 847db777..8d4a3ff0 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountManagementPage.xaml @@ -221,6 +221,25 @@ + + + +