Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af4c9527b0 | |||
| 0b9bdc91fe | |||
| c1bda75d9f | |||
| 6f82cd4f26 | |||
| 81e28129b7 | |||
| 39cde10fab | |||
| ed54eb0284 |
@@ -16,5 +16,6 @@ public enum AppLanguage
|
||||
Greek,
|
||||
PortugeseBrazil,
|
||||
Italian,
|
||||
Romanian
|
||||
Romanian,
|
||||
Korean
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
public interface IConfigurationService
|
||||
{
|
||||
bool Contains(string key);
|
||||
|
||||
void Set(string key, object value);
|
||||
T Get<T>(string key, T defaultValue = default);
|
||||
|
||||
|
||||
@@ -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<LegacyLocalMigrationPreview> DetectAsync(CancellationToken cancellationToken = default);
|
||||
Task<LegacyLocalMigrationResult> ImportAsync(CancellationToken cancellationToken = default);
|
||||
void MarkPromptDeferred();
|
||||
}
|
||||
@@ -127,6 +127,11 @@ public interface IPreferencesService : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
bool IsHardDeleteProtectionEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Setting: Show the empty-folder command for junk/spam folders.
|
||||
/// </summary>
|
||||
bool IsShowEmptyJunkFolderEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Setting: Thread mails into conversations.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<LegacyLocalMigrationProviderCount> ProviderCounts { get; init; } = [];
|
||||
public IReadOnlyList<LegacyLocalMigrationAccountPreview> Accounts { get; init; } = [];
|
||||
public IReadOnlyList<string> 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; }
|
||||
}
|
||||
@@ -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<LegacyLocalMigrationFailure> Failures { get; init; } = [];
|
||||
public IReadOnlyList<string> 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;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public class WinoTranslationDictionary : Dictionary<string, string>
|
||||
AppLanguage.Greek => "el_GR",
|
||||
AppLanguage.PortugeseBrazil => "pt_BR",
|
||||
AppLanguage.Romanian => "ro_RO",
|
||||
AppLanguage.Korean => "ko_KR",
|
||||
_ => "en_US",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Изтриване на този акаунт",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Защита от окончателно изтриване",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Покажи командата за изпразване на папка в папките Спам. Това действие няма да изисква потвърждение и ще изтрие всички имейли в папката за спам веднага.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Покажи командата за изпразване на папка Спам",
|
||||
"SettingsDiagnostics_Description": "За разработчици",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Споделете този идентификационен номер с разработчиците, когато ви помолят, за да получите помощ за проблемите, с които се сблъсквате в Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Идентификатор за диагностика",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Mostra la comanda per buidar la carpeta de correu brossa. Aquesta acció no demanarà confirmació i eliminarà immediatament tots els correus de la carpeta de correu brossa.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Mostra la comanda per buidar la carpeta de correu brossa",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Smazat tento účet",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Ochrana proti trvalému smazání",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Zobrazit příkaz pro vyprázdnění složky ve složkách Nevyžádaná pošta (spam). Tato akce nepožádá o potvrzení a ihned smaže všechny e-maily ve složce spamu.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Zobrazit příkaz pro vyprázdnění složky spamu",
|
||||
"SettingsDiagnostics_Description": "Pro vývojáře",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Vis kommandoen til at tøm mappen i Junk/Spam-mapperne. Denne handling vil ikke bede om bekræftelse og vil straks slette alle mails i spam-mappen.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Vis kommandoen til at tømme spam-mappen",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Dieses Konto löschen",
|
||||
"SettingsDeleteProtection_Description": "Sollte Wino jedes Mal nachfragen, wenn Sie eine Mail mit Umschalten + Entfernen permanent löschen möchten?",
|
||||
"SettingsDeleteProtection_Title": "Schutz vor permanenter Löschung",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Zeige den Befehl zum Leeren des Junk-/Spam-Ordners an. Diese Aktion wird keine Bestätigung erfordern und löscht sofort alle E-Mails im Spam-Ordner.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Befehl zum Leeren des Spam-Ordners anzeigen",
|
||||
"SettingsDiagnostics_Description": "Für Entwickler",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Teilen Sie diese ID mit den Entwicklern, wenn Sie um Hilfe bei Problemen in Wino Mail gebeten werden.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnose-ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Διαγραφή αυτού του λογαριασμού",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Προστασία Μόνιμης Διαγραφής",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Εμφάνιση της εντολής εκκένωσης φακέλου στους φακέλους Junk/Spam. Αυτή η ενέργεια δεν θα ζητήσει επιβεβαίωση και θα διαγράψει αμέσως όλα τα ηλεκτρονικά μηνύματα στον φάκελο Spam.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Εμφάνιση εντολής εκκένωσης φακέλου Spam",
|
||||
"SettingsDiagnostics_Description": "Για προγραμματιστές",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Μοιραστείτε αυτό το ID με τους προγραμματιστές όταν σας ζητηθεί να λάβετε βοήθεια για τα θέματα που αντιμετωπίζετε στο Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Διαγνωστικό ID",
|
||||
|
||||
@@ -92,11 +92,13 @@
|
||||
"Buttons_Discard": "Discard",
|
||||
"Buttons_Dismiss": "Dismiss",
|
||||
"Buttons_Edit": "Edit",
|
||||
"Buttons_EML": "EML",
|
||||
"Buttons_EnableImageRendering": "Enable",
|
||||
"Buttons_Multiselect": "Select Multiple",
|
||||
"Buttons_Manage": "Manage",
|
||||
"Buttons_No": "No",
|
||||
"Buttons_Open": "Open",
|
||||
"Buttons_PDF": "PDF",
|
||||
"Buttons_Purchase": "Purchase",
|
||||
"Buttons_RateWino": "Rate Wino",
|
||||
"Buttons_Reset": "Reset",
|
||||
@@ -595,6 +597,9 @@
|
||||
"Info_MessageCorruptedTitle": "Error",
|
||||
"Info_MissingFolderMessage": "{0} doesn't exist for this account.",
|
||||
"Info_MissingFolderTitle": "Missing Folder",
|
||||
"Info_EMLSaveFailedTitle": "Failed to save EML file",
|
||||
"Info_EMLSaveSuccessMessage": "EML file is saved to {0}",
|
||||
"Info_EMLSaveSuccessTitle": "Success",
|
||||
"Info_PDFSaveFailedTitle": "Failed to save PDF file",
|
||||
"Info_PDFSaveSuccessMessage": "PDF file is saved to {0}",
|
||||
"Info_PDFSaveSuccessTitle": "Success",
|
||||
@@ -836,6 +841,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Show the empty-folder command in Junk/Spam folders. This action will not ask for confirmation and will delete all mails in the spam folder immediately.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Show empty spam folder command",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
@@ -1361,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",
|
||||
@@ -1437,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",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Eliminar esta cuenta",
|
||||
"SettingsDeleteProtection_Description": "¿Debería Wino pedirte confirmación cada vez que intentas eliminar un correo usando las teclas Shift + Supr?",
|
||||
"SettingsDeleteProtection_Title": "Protección de Eliminación Permanente",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Mostrar el comando para vaciar la carpeta en las carpetas de correo no deseado. Esta acción no solicitará confirmación y eliminará todos los correos de la carpeta de correo no deseado de inmediato.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Mostrar el comando para vaciar la carpeta de correo no deseado",
|
||||
"SettingsDiagnostics_Description": "Para desarrolladores",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Comparte este ID con los desarrolladores cuando se les pida ayuda para los problemas que experimentas en Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnóstico",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Näytä roskapostikansioiden tyhjennyskomento. Tämä toimenpide ei kysy vahvistusta ja poistaa välittömästi kaikki sähköpostit roskapostikansioista.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Näytä roskapostikansioiden tyhjennyskomento",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Supprimer ce compte",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Protection contre la suppression permanente",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Afficher la commande de vidage du dossier dans les dossiers de courrier indésirable. Cette action ne demandera pas de confirmation et supprimera immédiatement tous les courriels du dossier de courrier indésirable.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Afficher la commande pour vider le dossier de courrier indésirable",
|
||||
"SettingsDiagnostics_Description": "Pour les développeurs",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Partagez cet identifiant avec les développeurs lorsqu'ils vous aideront à résoudre les problèmes que vous rencontrez dans Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID de diagnostic",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Mostrar o comando para baleirar a carpeta de correo lixo. Esta acción non pedirá confirmación e eliminará de inmediato todos os correos da carpeta de correo lixo.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Mostrar o comando para baleirar a carpeta de correo lixo",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Hapus akun ini",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Perlindungan Hapus Permanen",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Tampilkan perintah kosongkan folder pada folder Junk/Spam. Tindakan ini tidak akan meminta konfirmasi dan akan menghapus semua email di folder spam segera.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Perintah kosongkan folder Spam",
|
||||
"SettingsDiagnostics_Description": "Untuk pengembang",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Elimina questo account",
|
||||
"SettingsDeleteProtection_Description": "Dovrebbe Wino chiederti la conferma ogni volta che provi a eliminare definitivamente un messaggio utilizzando Maiusc + Canc?",
|
||||
"SettingsDeleteProtection_Title": "Protezione eliminazione permanente",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Mostra il comando per svuotare le cartelle Junk/Spam. Questa azione non richiederà alcuna conferma e eliminerà immediatamente tutte le email nelle cartelle Junk/Spam.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Mostra il comando per svuotare la cartella Junk/Spam",
|
||||
"SettingsDiagnostics_Description": "Per gli sviluppatori",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Condividi questo ID con gli sviluppatori quando richiesto per ricevere aiuto in merito ai problemi che hai con Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID diagnostica",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "迷惑メール フォルダーの「空にする」コマンドを表示します。この操作は確認を求めず、迷惑メール フォルダー内のすべてのメールを即座に削除します。",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "迷惑メール フォルダーを空にするコマンドを表示",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Delete this account",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Permanent Delete Protection",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Rodyti komandą Išvalyti šlamšto aplanką šlamšto aplankuose. Šis veiksmas nepaprašys patvirtinimo ir nedelsiant ištrins visus el. laiškus šlamšto aplanke.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Rodyti komandą Išvalyti šlamšto aplanką",
|
||||
"SettingsDiagnostics_Description": "For developers",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Verwijder dit account",
|
||||
"SettingsDeleteProtection_Description": "Moet Wino u om bevestiging vragen elke keer wanneer u een e-mail permanent verwijdert met de Shift + Del toetsen?",
|
||||
"SettingsDeleteProtection_Title": "Bescherming tegen permanent verwijderen",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Toon het lege-map-commando in de Junk/Spam-mappen. Deze actie vraagt geen bevestiging en verwijdert onmiddellijk alle e-mails in de spam-map.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Toon leeg-map-commando",
|
||||
"SettingsDiagnostics_Description": "Voor ontwikkelaars",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Deel dit ID met de ontwikkelaars wanneer er om hulp gevraagd wordt voor de problemen die u in Wino Mail ervaart.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostische ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Usuń to konto",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Ochrona przed trwałym usunięciem",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Pokaż polecenie opróżniania pustego folderu w folderach Junk/Spam. Ta akcja nie poprosi o potwierdzenie i natychmiast usunie wszystkie wiadomości z folderu Spam.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Pokaż polecenie opróżniania pustego folderu Spam",
|
||||
"SettingsDiagnostics_Description": "Dla programistów",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Apagar esta conta",
|
||||
"SettingsDeleteProtection_Description": "O Wino deve solicitar confirmação sempre que você tentar excluir permanentemente um e-mail usando as teclas Shift + Del?",
|
||||
"SettingsDeleteProtection_Title": "Proteção de exclusão permanente",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Mostrar o comando para esvaziar a pasta nas pastas de Lixo/Spam. Esta ação não solicitará confirmação e excluirá imediatamente todas as mensagens na pasta de spam.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Mostrar o comando para esvaziar a pasta de Spam",
|
||||
"SettingsDiagnostics_Description": "Para desenvolvedores",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Compartilhe esta identificação com os desenvolvedores quando solicitado para obter ajuda para os problemas que você experimentar no Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID de diagnóstico",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Ștergeți acest cont",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Protecție Ștergere Permanentă",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Afișează comanda de golire a folderelor Junk/Spam. Această acțiune nu va solicita confirmare și va șterge imediat toate mesajele din folderul de spam.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Afișează comanda pentru golirea folderului de spam.",
|
||||
"SettingsDiagnostics_Description": "Pentru dezvoltatori",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Partajați acest ID cu dezvoltatorii atunci când le cereți ajutor pentru problemele pe care le întâmpinați în Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnosticare",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Удалить эту учетную запись",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Защита от окончательного удаления",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Показать команду очистки пустой папки в папках Спам. Это действие не будет запрашивать подтверждение и сразу удалит все письма в папке спама.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Показать команду очистки пустой папки в папках Спам",
|
||||
"SettingsDiagnostics_Description": "Для разработчиков",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Odstrániť tento účet",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Ochrana pred trvalým odstránením",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Zobraziť príkaz na vyprázdnenie priečinka v priečinkoch Nevyžiadaná pošta / Spam. Táto akcia nepožaduje potvrdenie a okamžite vymaže všetky e-maily zo spamového priečinka.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Zobraziť príkaz na vyprázdnenie spamového priečinka",
|
||||
"SettingsDiagnostics_Description": "Pre vývojárov",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Zdieľajte toto ID s vývojármi, keď budete požiadaní o pomoc pri problémoch, ktoré sa vám vyskytnú v aplikácii Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostické ID",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Bu hesabı sil",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Kalıcı Silme Koruması",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Gereksiz Posta/Spam klasörlerinde boş klasör komutunu göster. Bu işlem onay istemeyecek ve spam klasöründeki tüm mailleri hemen silecektir.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Boş Spam Klasörü Komutunu Göster",
|
||||
"SettingsDiagnostics_Description": "Geliştiriciler için",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Bu ID'yi geliştiriciler ile paylaşarak Wino Mail ile yaşadığınız sorunlar hakkında çözümlere ulaşabilirsiniz.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "Hata Ayıklama ID'si",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "Видалити цей обліковий запис",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "Захист від остаточного видалення",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "Показати команду очищення порожньої папки у папках Небажана пошта/Спам. Ця дія не вимагатиме підтвердження і негайно видалить усі листи з папки зі спамом.",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "Показати команду очищення порожньої папки Спам",
|
||||
"SettingsDiagnostics_Description": "Для розробників",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "Поділіться цим ID з розробниками, коли попросять, щоб отримати допомогу щодо проблем, з якими Ви зіткнулися у Wino Mail.",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "ID діагностики",
|
||||
|
||||
@@ -836,6 +836,8 @@
|
||||
"SettingsDeleteAccount_Title": "删除此账户",
|
||||
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
|
||||
"SettingsDeleteProtection_Title": "永久性删除保护",
|
||||
"SettingsEmptyJunkFolderCommand_Description": "在垃圾邮件/垃圾箱文件夹中显示清空文件夹命令。此操作不会要求确认,将立即删除垃圾邮件文件夹中的所有邮件。",
|
||||
"SettingsEmptyJunkFolderCommand_Title": "显示清空垃圾邮件文件夹的命令",
|
||||
"SettingsDiagnostics_Description": "开发者选项",
|
||||
"SettingsDiagnostics_DiagnosticId_Description": "如需联系开发人员请求帮助,请提供此 ID。",
|
||||
"SettingsDiagnostics_DiagnosticId_Title": "诊断 ID",
|
||||
|
||||
@@ -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<LegacyMigrationTestContext> CreateAsync(LegacySchemaOptions? schemaOptions = null)
|
||||
{
|
||||
var databaseService = new InMemoryDatabaseService();
|
||||
await databaseService.InitializeAsync();
|
||||
|
||||
var preferencesService = new Mock<IPreferencesService>();
|
||||
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<string, string?> _localValues = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string?> _roamingValues = new(StringComparer.Ordinal);
|
||||
|
||||
public bool Contains(string key) => _localValues.ContainsKey(key);
|
||||
|
||||
public T Get<T>(string key, T defaultValue = default!)
|
||||
=> TryGetValue(_localValues, key, defaultValue);
|
||||
|
||||
public T GetRoaming<T>(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<T>(Dictionary<string, string?> 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<string>
|
||||
{
|
||||
"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<string>
|
||||
{
|
||||
"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<ISignatureService>();
|
||||
signatureService
|
||||
.Setup(a => a.CreateDefaultSignatureAsync(It.IsAny<Guid>()))
|
||||
.ReturnsAsync((Guid accountId) => new AccountSignature
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MailAccountId = accountId,
|
||||
Name = "Default",
|
||||
HtmlBody = string.Empty
|
||||
});
|
||||
|
||||
return new AccountService(
|
||||
databaseService,
|
||||
signatureService.Object,
|
||||
Mock.Of<IAuthenticationProvider>(),
|
||||
Mock.Of<IMimeFileService>(),
|
||||
preferencesService,
|
||||
Mock.Of<IContactPictureFileService>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Services;
|
||||
|
||||
public class TranslationServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("pl-PL", AppLanguage.Polish)]
|
||||
[InlineData("de-AT", AppLanguage.Deutsch)]
|
||||
[InlineData("pt-PT", AppLanguage.PortugeseBrazil)]
|
||||
[InlineData("zh-TW", AppLanguage.Chinese)]
|
||||
[InlineData("tr_TR", AppLanguage.Turkish)]
|
||||
[InlineData("ko-KR", AppLanguage.Korean)]
|
||||
[InlineData("nl-NL", AppLanguage.English)]
|
||||
public void ResolveSupportedLanguage_ReturnsExpectedLanguage(string languageTag, AppLanguage expectedLanguage)
|
||||
{
|
||||
var result = TranslationService.ResolveSupportedLanguage([languageTag]);
|
||||
|
||||
result.Should().Be(expectedLanguage);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,32 @@ namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public sealed class GmailSynchronizerRequestSuccessTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateAccountSyncIdentifierAsync_EmptyStoredIdentifier_PersistsFirstHistoryCursor()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
changeProcessor
|
||||
.Setup(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"))
|
||||
.ReturnsAsync("123");
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: string.Empty);
|
||||
|
||||
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
|
||||
|
||||
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAccountSyncIdentifierAsync_OlderHistoryCursor_DoesNotRegressStoredCursor()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: "456");
|
||||
|
||||
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
|
||||
|
||||
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail()
|
||||
{
|
||||
@@ -209,13 +235,15 @@ public sealed class GmailSynchronizerRequestSuccessTests
|
||||
|
||||
private static GmailSynchronizer CreateSynchronizer(
|
||||
IGmailChangeProcessor changeProcessor,
|
||||
IGmailSynchronizerErrorHandlerFactory? errorFactory = null)
|
||||
IGmailSynchronizerErrorHandlerFactory? errorFactory = null,
|
||||
string? synchronizationDeltaIdentifier = null)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Gmail",
|
||||
Address = "user@example.com"
|
||||
Address = "user@example.com",
|
||||
SynchronizationDeltaIdentifier = synchronizationDeltaIdentifier
|
||||
};
|
||||
|
||||
var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose);
|
||||
@@ -249,4 +277,17 @@ public sealed class GmailSynchronizerRequestSuccessTests
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
}
|
||||
|
||||
private static async Task InvokeUpdateAccountSyncIdentifierAsync(GmailSynchronizer synchronizer, ulong historyId)
|
||||
{
|
||||
var method = typeof(GmailSynchronizer).GetMethod(
|
||||
"UpdateAccountSyncIdentifierAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var task = method!.Invoke(synchronizer, [historyId]) as Task;
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1897,9 +1897,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
if (historyId == null) return false;
|
||||
|
||||
var newHistoryId = historyId.Value;
|
||||
var currentSynchronizationIdentifier = Account.SynchronizationDeltaIdentifier;
|
||||
|
||||
return Account.SynchronizationDeltaIdentifier == null ||
|
||||
(ulong.TryParse(Account.SynchronizationDeltaIdentifier, out ulong currentIdentifier) && newHistoryId > currentIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(currentSynchronizationIdentifier))
|
||||
return true;
|
||||
|
||||
if (!ulong.TryParse(currentSynchronizationIdentifier, out ulong currentIdentifier))
|
||||
{
|
||||
_logger.Warning("Current Gmail history ID '{HistoryId}' is invalid for {Name}. Replacing it with {NewHistoryId}.",
|
||||
currentSynchronizationIdentifier, Account.Name, newHistoryId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return newHistoryId > currentIdentifier;
|
||||
}
|
||||
|
||||
private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId)
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
@@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using MimeKit;
|
||||
using MimeKit.Cryptography;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
@@ -37,9 +38,11 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
public event EventHandler CloseRequested;
|
||||
|
||||
private static readonly TimeSpan LocalDraftRetryGracePeriod = TimeSpan.FromSeconds(15);
|
||||
private const string MimeFileName = "mail.eml";
|
||||
|
||||
public Func<Task<string>> GetHTMLBodyFunction;
|
||||
public Func<string, Task> RenderHtmlBodyAsyncFunc { get; set; }
|
||||
public Func<string, Task<bool>> SaveHTMLasPDFFunc { get; set; }
|
||||
|
||||
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
|
||||
{
|
||||
@@ -440,6 +443,77 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
await _mimeFileService.SaveMimeMessageAsync(CurrentMailDraftItem.MailCopy.FileId, CurrentMimeMessage, ComposingAccount.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ExportAsPdfAsync()
|
||||
{
|
||||
if (SaveHTMLasPDFFunc == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var pickedFilePath = await _dialogService.PickFilePathAsync($"{GetSuggestedExportFileName()}.pdf").ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pickedFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isSaved = await SaveHTMLasPDFFunc(pickedFilePath).ConfigureAwait(false);
|
||||
|
||||
if (isSaved)
|
||||
{
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_PDFSaveSuccessTitle,
|
||||
string.Format(Translator.Info_PDFSaveSuccessMessage, pickedFilePath),
|
||||
InfoBarMessageType.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to save compose draft as PDF.");
|
||||
_dialogService.InfoBarMessage(Translator.Info_PDFSaveFailedTitle, ex.Message, InfoBarMessageType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExportAsEmlAsync()
|
||||
{
|
||||
if (CurrentMailDraftItem?.MailCopy == null || ComposingAccount == null)
|
||||
{
|
||||
_dialogService.InfoBarMessage(Translator.Info_ComposerMissingMIMETitle, Translator.Info_ComposerMissingMIMEMessage, InfoBarMessageType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await UpdateMimeChangesAsync().ConfigureAwait(false);
|
||||
|
||||
var pickedFilePath = await _dialogService.PickFilePathAsync($"{GetSuggestedExportFileName()}.eml").ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pickedFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resourcePath = await _mimeFileService
|
||||
.GetMimeResourcePathAsync(ComposingAccount.Id, CurrentMailDraftItem.MailCopy.FileId)
|
||||
.ConfigureAwait(false);
|
||||
var sourceFilePath = Path.Combine(resourcePath, MimeFileName);
|
||||
|
||||
File.Copy(sourceFilePath, pickedFilePath, true);
|
||||
|
||||
_dialogService.InfoBarMessage(
|
||||
Translator.Info_EMLSaveSuccessTitle,
|
||||
string.Format(Translator.Info_EMLSaveSuccessMessage, pickedFilePath),
|
||||
InfoBarMessageType.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to save compose draft as EML.");
|
||||
_dialogService.InfoBarMessage(Translator.Info_EMLSaveFailedTitle, ex.Message, InfoBarMessageType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateMailCopyAsync()
|
||||
{
|
||||
CurrentMailDraftItem.Subject = CurrentMimeMessage.Subject;
|
||||
@@ -879,6 +953,29 @@ public partial class ComposePageViewModel : MailBaseViewModel,
|
||||
list.Add(new MailboxAddress(item.Name, item.Address));
|
||||
}
|
||||
|
||||
private string GetSuggestedExportFileName()
|
||||
{
|
||||
var subject = string.IsNullOrWhiteSpace(Subject) ? Translator.MailItemNoSubject : Subject;
|
||||
return SanitizeFileNamePart(subject);
|
||||
}
|
||||
|
||||
private static string SanitizeFileNamePart(string value)
|
||||
{
|
||||
var invalidCharacters = Path.GetInvalidFileNameChars();
|
||||
var sanitizedChars = value.Trim().ToCharArray();
|
||||
|
||||
for (var i = 0; i < sanitizedChars.Length; i++)
|
||||
{
|
||||
if (Array.IndexOf(invalidCharacters, sanitizedChars[i]) >= 0)
|
||||
{
|
||||
sanitizedChars[i] = '_';
|
||||
}
|
||||
}
|
||||
|
||||
var sanitized = new string(sanitizedChars).Trim();
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "email" : sanitized;
|
||||
}
|
||||
|
||||
private async Task<(DraftCreationReason reason, MailCopy referenceMailCopy)> ResolveRetryDraftContextAsync()
|
||||
{
|
||||
if (CurrentMimeMessage == null || CurrentMailDraftItem?.MailCopy?.AssignedAccount == null)
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
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<string>();
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -217,6 +217,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
{
|
||||
base.OnNavigatedTo(mode, parameters);
|
||||
|
||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
|
||||
MailCollection.ItemSelectionChanged += MailItemSelectionChanged;
|
||||
}
|
||||
|
||||
@@ -224,6 +226,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
{
|
||||
base.OnNavigatedFrom(mode, parameters);
|
||||
|
||||
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
|
||||
MailCollection.ItemSelectionChanged -= MailItemSelectionChanged;
|
||||
|
||||
await MailCollection.ClearAsync();
|
||||
@@ -296,7 +299,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
public bool IsJunkFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Junk;
|
||||
public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
|
||||
public bool IsSyncButtonVisible => !IsCategoryView;
|
||||
public bool IsEmptyFolderButtonVisible => IsJunkFolder;
|
||||
public bool IsEmptyFolderButtonVisible => IsJunkFolder && PreferencesService.IsShowEmptyJunkFolderEnabled;
|
||||
|
||||
public string SelectedMessageText => IsDragInProgress
|
||||
? string.Format(Translator.MailsDragging, DraggingItemsCount)
|
||||
@@ -386,6 +389,18 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
OnPropertyChanged(nameof(IsFolderEmpty));
|
||||
}
|
||||
|
||||
private async void PreferencesServiceChanged(object sender, string propertyName)
|
||||
{
|
||||
if (propertyName != nameof(IPreferencesService.IsShowEmptyJunkFolderEnabled))
|
||||
return;
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEmptyFolderButtonVisible));
|
||||
EmptyFolderCommand.NotifyCanExecuteChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async void UpdateBarMessage(InfoBarMessageType severity, string title, string message)
|
||||
{
|
||||
await ExecuteUIThread(() =>
|
||||
@@ -588,7 +603,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanEmptyFolder() => IsJunkFolder && !IsAccountSynchronizerInSynchronization;
|
||||
private bool CanEmptyFolder() => IsEmptyFolderButtonVisible && !IsAccountSynchronizerInSynchronization;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanLoadMoreItems))]
|
||||
private async Task LoadMoreItemsAsync()
|
||||
|
||||
@@ -20,47 +20,18 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
IAccountService accountService) : MailBaseViewModel
|
||||
{
|
||||
public ObservableCollection<AccountSignature> Signatures { get; set; } = [];
|
||||
private bool isLoaded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSignatureEnabled;
|
||||
|
||||
private int signatureForNewMessagesIndex;
|
||||
public partial bool IsSignatureEnabled { get; set; }
|
||||
|
||||
public Guid EmptyGuid { get; } = Guid.Empty;
|
||||
|
||||
public int SignatureForNewMessagesIndex
|
||||
{
|
||||
get => signatureForNewMessagesIndex;
|
||||
set
|
||||
{
|
||||
if (value == -1)
|
||||
{
|
||||
SetProperty(ref signatureForNewMessagesIndex, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetProperty(ref signatureForNewMessagesIndex, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial AccountSignature SelectedSignatureForNewMessages { get; set; }
|
||||
|
||||
private int signatureForFollowingMessagesIndex;
|
||||
|
||||
public int SignatureForFollowingMessagesIndex
|
||||
{
|
||||
get => signatureForFollowingMessagesIndex;
|
||||
set
|
||||
{
|
||||
if (value == -1)
|
||||
{
|
||||
SetProperty(ref signatureForFollowingMessagesIndex, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetProperty(ref signatureForFollowingMessagesIndex, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial AccountSignature SelectedSignatureForFollowingMessages { get; set; }
|
||||
|
||||
private MailAccount Account { get; set; }
|
||||
|
||||
@@ -71,6 +42,7 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||
{
|
||||
base.OnNavigatedTo(mode, parameters);
|
||||
isLoaded = false;
|
||||
|
||||
if (parameters is Guid accountId)
|
||||
Account = await _accountService.GetAccountAsync(accountId);
|
||||
@@ -78,36 +50,43 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
if (Account == null) return;
|
||||
|
||||
var dbSignatures = await _signatureService.GetSignaturesAsync(Account.Id);
|
||||
IsSignatureEnabled = Account.Preferences.IsSignatureEnabled;
|
||||
var noneSignature = new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName };
|
||||
var signatureForNewMessages = dbSignatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForNewMessages) ?? noneSignature;
|
||||
var signatureForFollowingMessages = dbSignatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForFollowingMessages) ?? noneSignature;
|
||||
|
||||
Signatures.Clear();
|
||||
Signatures.Add(new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName });
|
||||
dbSignatures.ForEach(Signatures.Add);
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
IsSignatureEnabled = Account.Preferences.IsSignatureEnabled;
|
||||
|
||||
SignatureForNewMessagesIndex = Signatures.IndexOf(Signatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForNewMessages));
|
||||
SignatureForFollowingMessagesIndex = Signatures.IndexOf(Signatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForFollowingMessages));
|
||||
Signatures.Clear();
|
||||
Signatures.Add(noneSignature);
|
||||
dbSignatures.ForEach(Signatures.Add);
|
||||
|
||||
SelectedSignatureForNewMessages = signatureForNewMessages;
|
||||
SelectedSignatureForFollowingMessages = signatureForFollowingMessages;
|
||||
});
|
||||
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
base.OnPropertyChanged(e);
|
||||
|
||||
if (!isLoaded || Account?.Preferences == null) return;
|
||||
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(IsSignatureEnabled):
|
||||
Account.Preferences.IsSignatureEnabled = IsSignatureEnabled;
|
||||
await _accountService.UpdateAccountAsync(Account);
|
||||
break;
|
||||
case nameof(SignatureForNewMessagesIndex):
|
||||
Account.Preferences.SignatureIdForNewMessages = SignatureForNewMessagesIndex > -1
|
||||
&& Signatures[SignatureForNewMessagesIndex].Id != EmptyGuid
|
||||
? Signatures[SignatureForNewMessagesIndex].Id : null;
|
||||
case nameof(SelectedSignatureForNewMessages):
|
||||
Account.Preferences.SignatureIdForNewMessages = GetPersistedSignatureId(SelectedSignatureForNewMessages);
|
||||
await _accountService.UpdateAccountAsync(Account);
|
||||
break;
|
||||
case nameof(SignatureForFollowingMessagesIndex):
|
||||
Account.Preferences.SignatureIdForFollowingMessages = SignatureForFollowingMessagesIndex > -1
|
||||
&& Signatures[SignatureForFollowingMessagesIndex].Id != EmptyGuid
|
||||
? Signatures[SignatureForFollowingMessagesIndex].Id : null;
|
||||
case nameof(SelectedSignatureForFollowingMessages):
|
||||
Account.Preferences.SignatureIdForFollowingMessages = GetPersistedSignatureId(SelectedSignatureForFollowingMessages);
|
||||
await _accountService.UpdateAccountAsync(Account);
|
||||
break;
|
||||
}
|
||||
@@ -121,7 +100,7 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
if (dialogResult == null) return;
|
||||
|
||||
dialogResult.MailAccountId = Account.Id;
|
||||
Signatures.Add(dialogResult);
|
||||
await ExecuteUIThread(() => Signatures.Add(dialogResult));
|
||||
await _signatureService.CreateSignatureAsync(dialogResult);
|
||||
}
|
||||
|
||||
@@ -133,18 +112,23 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
if (dialogResult == null) return;
|
||||
|
||||
var indexOfCurrentSignature = Signatures.IndexOf(signatureModel);
|
||||
var signatureNewMessagesIndex = SignatureForNewMessagesIndex;
|
||||
var signatureFollowingMessagesIndex = SignatureForFollowingMessagesIndex;
|
||||
if (indexOfCurrentSignature < 0) return;
|
||||
|
||||
Signatures[indexOfCurrentSignature] = dialogResult;
|
||||
var wasSelectedForNewMessages = SelectedSignatureForNewMessages?.Id == signatureModel.Id;
|
||||
var wasSelectedForFollowingMessages = SelectedSignatureForFollowingMessages?.Id == signatureModel.Id;
|
||||
|
||||
// Reset selection to point updated signature.
|
||||
// When Item updated/removed index switches to -1. We save index that was used before and update -1 to it.
|
||||
if (signatureNewMessagesIndex == indexOfCurrentSignature)
|
||||
SignatureForNewMessagesIndex = indexOfCurrentSignature;
|
||||
dialogResult.MailAccountId = signatureModel.MailAccountId;
|
||||
|
||||
if (signatureFollowingMessagesIndex == indexOfCurrentSignature)
|
||||
SignatureForFollowingMessagesIndex = indexOfCurrentSignature;
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
Signatures[indexOfCurrentSignature] = dialogResult;
|
||||
|
||||
if (wasSelectedForNewMessages)
|
||||
SelectedSignatureForNewMessages = dialogResult;
|
||||
|
||||
if (wasSelectedForFollowingMessages)
|
||||
SelectedSignatureForFollowingMessages = dialogResult;
|
||||
});
|
||||
|
||||
await _signatureService.UpdateSignatureAsync(dialogResult);
|
||||
}
|
||||
@@ -156,7 +140,31 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
|
||||
|
||||
if (!shouldRemove) return;
|
||||
|
||||
Signatures.Remove(signatureModel);
|
||||
var shouldResetNewMessagesSignature = SelectedSignatureForNewMessages?.Id == signatureModel.Id;
|
||||
var shouldResetFollowingMessagesSignature = SelectedSignatureForFollowingMessages?.Id == signatureModel.Id;
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
Signatures.Remove(signatureModel);
|
||||
|
||||
var noneSignature = GetNoneSignature();
|
||||
|
||||
if (shouldResetNewMessagesSignature)
|
||||
SelectedSignatureForNewMessages = noneSignature;
|
||||
|
||||
if (shouldResetFollowingMessagesSignature)
|
||||
SelectedSignatureForFollowingMessages = noneSignature;
|
||||
});
|
||||
|
||||
await _signatureService.DeleteSignatureAsync(signatureModel);
|
||||
}
|
||||
|
||||
private Guid? GetPersistedSignatureId(AccountSignature signature)
|
||||
=> signature?.Id is Guid signatureId && signatureId != EmptyGuid
|
||||
? signatureId
|
||||
: null;
|
||||
|
||||
private AccountSignature GetNoneSignature()
|
||||
=> Signatures.FirstOrDefault(x => x.Id == EmptyGuid)
|
||||
?? new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName };
|
||||
}
|
||||
|
||||
@@ -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<UpdateNoteSection> 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)
|
||||
{
|
||||
|
||||
@@ -479,8 +479,8 @@ public partial class App : WinoApplication,
|
||||
|
||||
EnsureAppNotificationRegistration();
|
||||
|
||||
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
||||
.RunIfNeededAsync();
|
||||
await Services.GetRequiredService<ILegacyLocalMigrationService>()
|
||||
.DetectAsync();
|
||||
|
||||
await InitializeServicesAsync();
|
||||
|
||||
@@ -490,6 +490,9 @@ public partial class App : WinoApplication,
|
||||
|
||||
_hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any();
|
||||
|
||||
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
||||
.RunIfNeededAsync();
|
||||
|
||||
_activationInfrastructureInitialized = true;
|
||||
}
|
||||
finally
|
||||
@@ -1502,7 +1505,9 @@ public partial class App : WinoApplication,
|
||||
|
||||
Services.GetRequiredService<IMailDialogService>().InfoBarMessage(
|
||||
Translator.GeneralTitle_Info,
|
||||
Translator.WinoAccount_Management_ImportReloginReminder,
|
||||
string.IsNullOrWhiteSpace(message.CompletionMessage)
|
||||
? Translator.WinoAccount_Management_ImportReloginReminder
|
||||
: message.CompletionMessage,
|
||||
InfoBarMessageType.Information);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
@@ -24,9 +23,6 @@ public sealed partial class SignatureEditorDialog : ContentDialog
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SignatureNameTextBox.Text = signatureModel.Name.Trim();
|
||||
SignatureNameTextBox.Header = string.Format(Translator.SignatureEditorDialog_SignatureName_TitleEdit, signatureModel.Name);
|
||||
|
||||
Result = new AccountSignature
|
||||
{
|
||||
Id = signatureModel.Id,
|
||||
@@ -35,6 +31,9 @@ public sealed partial class SignatureEditorDialog : ContentDialog
|
||||
HtmlBody = signatureModel.HtmlBody
|
||||
};
|
||||
|
||||
SignatureNameTextBox.Text = Result.Name.Trim();
|
||||
SignatureNameTextBox.Header = string.Format(Translator.SignatureEditorDialog_SignatureName_TitleEdit, Result.Name);
|
||||
|
||||
// TODO: Should be added additional logic to enable/disable primary button when webview content changed.
|
||||
IsPrimaryButtonEnabled = true;
|
||||
}
|
||||
@@ -51,7 +50,7 @@ public sealed partial class SignatureEditorDialog : ContentDialog
|
||||
|
||||
private async void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
var newSignature = Regex.Unescape((await WebViewEditor.GetHtmlBodyAsync())!);
|
||||
var newSignature = await WebViewEditor.GetHtmlBodyAsync() ?? string.Empty;
|
||||
|
||||
if (Result == null)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ const joditConfig = {
|
||||
"showWordsCounter": false,
|
||||
"showXPathInStatusbar": false,
|
||||
"spellcheck": true,
|
||||
"defaultActionOnPaste": "insert_as_text",
|
||||
"link": {
|
||||
"processVideoLink": false
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<Identity
|
||||
Name="58272BurakKSE.WinoMailPreview"
|
||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
||||
Version="2.0.4.0" />
|
||||
Version="2.0.6.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Wino.Mail.WinUI.Services;
|
||||
|
||||
public class ConfigurationService : IConfigurationService
|
||||
{
|
||||
public bool Contains(string key)
|
||||
=> ApplicationData.Current.LocalSettings.Values.ContainsKey(key);
|
||||
|
||||
public T Get<T>(string key, T defaultValue = default!)
|
||||
=> GetInternal(key, ApplicationData.Current.LocalSettings.Values, defaultValue);
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
|
||||
set => SetPropertyAndSave(nameof(IsHardDeleteProtectionEnabled), value);
|
||||
}
|
||||
|
||||
public bool IsShowEmptyJunkFolderEnabled
|
||||
{
|
||||
get => _configurationService.Get(nameof(IsShowEmptyJunkFolderEnabled), false);
|
||||
set => SetPropertyAndSave(nameof(IsShowEmptyJunkFolderEnabled), value);
|
||||
}
|
||||
|
||||
public bool IsThreadingEnabled
|
||||
{
|
||||
get => _configurationService.Get(nameof(IsThreadingEnabled), true);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -221,6 +221,25 @@
|
||||
<SymbolIcon Symbol="Account" />
|
||||
</winuiControls:SettingsCard.HeaderIcon>
|
||||
</winuiControls:SettingsCard>
|
||||
<winuiControls:SettingsCard
|
||||
Description="{x:Bind ViewModel.LegacyMigrationSummary, Mode=OneWay}"
|
||||
Header="{x:Bind domain:Translator.LegacyLocalMigration_SettingsSectionTitle, Mode=OneTime}"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportAvailable, Mode=OneWay}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.LegacyMigrationWarningSummary, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
|
||||
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
<winuiControls:SettingsCard.HeaderIcon>
|
||||
<SymbolIcon Symbol="Sync" />
|
||||
</winuiControls:SettingsCard.HeaderIcon>
|
||||
</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}" />
|
||||
|
||||
@@ -185,6 +185,17 @@
|
||||
<coreControls:WinoFontIcon Icon="{x:Bind GetEditorThemeIcon(WebViewEditor.IsEditorDarkMode), Mode=OneWay}" />
|
||||
</AppBarButton.Icon>
|
||||
</AppBarButton>
|
||||
<AppBarButton Label="{x:Bind domain:Translator.MailOperation_SaveAs}">
|
||||
<AppBarButton.Icon>
|
||||
<coreControls:WinoFontIcon Icon="Save" />
|
||||
</AppBarButton.Icon>
|
||||
<AppBarButton.Flyout>
|
||||
<coreControls:WinoMenuFlyout Placement="BottomEdgeAlignedRight">
|
||||
<MenuFlyoutItem Click="ExportPdf_Click" Text="{x:Bind domain:Translator.Buttons_PDF}" />
|
||||
<MenuFlyoutItem Click="ExportEml_Click" Text="{x:Bind domain:Translator.Buttons_EML}" />
|
||||
</coreControls:WinoMenuFlyout>
|
||||
</AppBarButton.Flyout>
|
||||
</AppBarButton>
|
||||
<AppBarButton Command="{x:Bind ViewModel.DiscardCommand}" Label="{x:Bind domain:Translator.Buttons_Discard}">
|
||||
<AppBarButton.Icon>
|
||||
<coreControls:WinoFontIcon Icon="Delete" />
|
||||
|
||||
@@ -59,6 +59,17 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
public ComposePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
ViewModel.SaveHTMLasPDFFunc = async path =>
|
||||
{
|
||||
var webView = GetWebView();
|
||||
|
||||
if (webView?.CoreWebView2 == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await webView.CoreWebView2.PrintToPdfAsync(path, null);
|
||||
};
|
||||
ViewModel.CloseRequested += ViewModel_CloseRequested;
|
||||
}
|
||||
|
||||
@@ -495,6 +506,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
FocusManager.GotFocus -= GlobalFocusManagerGotFocus;
|
||||
ComposeAiActionsPanel.CancelPendingOperation();
|
||||
await ViewModel.UpdateMimeChangesAsync();
|
||||
ViewModel.SaveHTMLasPDFFunc = null;
|
||||
ViewModel.RenderHtmlBodyAsyncFunc = null;
|
||||
|
||||
DisposeDisposables();
|
||||
@@ -565,6 +577,16 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
||||
}
|
||||
}
|
||||
|
||||
private async void ExportPdf_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await ViewModel.ExportAsPdfAsync();
|
||||
}
|
||||
|
||||
private async void ExportEml_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await ViewModel.ExportAsEmlAsync();
|
||||
}
|
||||
|
||||
protected override void RegisterRecipients()
|
||||
{
|
||||
base.RegisterRecipients();
|
||||
|
||||
@@ -15,21 +15,43 @@
|
||||
MaxWidth="900"
|
||||
Padding="20"
|
||||
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>
|
||||
<StackPanel Spacing="12">
|
||||
<winuiControls:SettingsCard
|
||||
Description="{x:Bind ViewModel.LegacyMigrationSummary, Mode=OneWay}"
|
||||
Header="{x:Bind domain:Translator.LegacyLocalMigration_SettingsSectionTitle, Mode=OneTime}"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportAvailable, Mode=OneWay}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.LegacyMigrationWarningSummary, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
|
||||
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
<winuiControls:SettingsCard.HeaderIcon>
|
||||
<SymbolIcon Symbol="Sync" />
|
||||
</winuiControls:SettingsCard.HeaderIcon>
|
||||
</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>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</abstract:ManageAccountsPageAbstract>
|
||||
|
||||
@@ -160,6 +160,10 @@
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.IsHardDeleteProtectionEnabled, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsEmptyJunkFolderCommand_Description}" Header="{x:Bind domain:Translator.SettingsEmptyJunkFolderCommand_Title}">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.IsShowEmptyJunkFolderEnabled, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Description}" Header="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Title}">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.AutoSelectNextItem, Mode=TwoWay}" />
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
IsActionIconVisible="False"
|
||||
IsClickEnabled="False"
|
||||
IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}">
|
||||
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedIndex="{x:Bind ViewModel.SignatureForNewMessagesIndex, Mode=TwoWay}">
|
||||
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedItem="{x:Bind ViewModel.SelectedSignatureForNewMessages, Mode=TwoWay}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="entities:AccountSignature">
|
||||
<TextBlock Text="{x:Bind Name}" />
|
||||
@@ -91,7 +91,7 @@
|
||||
IsActionIconVisible="False"
|
||||
IsClickEnabled="False"
|
||||
IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}">
|
||||
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedIndex="{x:Bind ViewModel.SignatureForFollowingMessagesIndex, Mode=TwoWay}">
|
||||
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedItem="{x:Bind ViewModel.SelectedSignatureForFollowingMessages, Mode=TwoWay}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="entities:AccountSignature">
|
||||
<TextBlock Text="{x:Bind Name}" />
|
||||
|
||||
@@ -147,9 +147,30 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
|
||||
|
||||
public void Receive(SettingsRootNavigationRequested message)
|
||||
{
|
||||
var currentRootPage = SettingsNavigationInfoProvider.GetRootPage(PageHistory.LastOrDefault()?.Request.PageType ?? WinoPage.SettingOptionsPage);
|
||||
if (message.PageType != WinoPage.SettingOptionsPage && currentRootPage == message.PageType)
|
||||
var activePage = PageHistory.LastOrDefault()?.Request.PageType ?? WinoPage.SettingOptionsPage;
|
||||
var currentRootPage = SettingsNavigationInfoProvider.GetRootPage(activePage);
|
||||
|
||||
if (message.PageType == currentRootPage)
|
||||
{
|
||||
if (activePage == currentRootPage)
|
||||
return;
|
||||
|
||||
var currentRootIndex = PageHistory
|
||||
.Select((item, index) => new { item.Request.PageType, Index = index })
|
||||
.FirstOrDefault(item => item.PageType == currentRootPage)?.Index ?? -1;
|
||||
|
||||
if (TryNavigateToBreadcrumbIndex(currentRootIndex))
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.PageType == WinoPage.SettingOptionsPage)
|
||||
{
|
||||
if (activePage == WinoPage.SettingOptionsPage)
|
||||
return;
|
||||
|
||||
NavigateToSettingsHome();
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateToRootPage(message.PageType);
|
||||
}
|
||||
@@ -196,11 +217,7 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
|
||||
|
||||
private void NavigateToRootPage(WinoPage targetPage)
|
||||
{
|
||||
PageHistory.Clear();
|
||||
SettingsFrame.BackStack.Clear();
|
||||
SettingsFrame.ForwardStack.Clear();
|
||||
|
||||
NavigateBreadcrumb(new BreadcrumbNavigationRequested(Translator.MenuSettings, WinoPage.SettingOptionsPage));
|
||||
NavigateToSettingsHome();
|
||||
|
||||
if (targetPage != WinoPage.SettingOptionsPage)
|
||||
{
|
||||
@@ -213,6 +230,43 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
|
||||
UpdateWindowTitle();
|
||||
}
|
||||
|
||||
private void NavigateToSettingsHome()
|
||||
{
|
||||
if (PageHistory.Count == 0 || SettingsFrame.Content == null)
|
||||
{
|
||||
ResetToSettingsHome();
|
||||
return;
|
||||
}
|
||||
|
||||
if (PageHistory.Count == 1)
|
||||
return;
|
||||
|
||||
if (!TryNavigateToBreadcrumbIndex(0))
|
||||
{
|
||||
ResetToSettingsHome();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryNavigateToBreadcrumbIndex(int targetIndex)
|
||||
{
|
||||
if (!BreadcrumbNavigationHelper.NavigateTo(SettingsFrame, PageHistory, targetIndex))
|
||||
return false;
|
||||
|
||||
UpdateBackNavigationState();
|
||||
_ = RefreshCurrentPageStateAsync();
|
||||
UpdateWindowTitle();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ResetToSettingsHome()
|
||||
{
|
||||
PageHistory.Clear();
|
||||
SettingsFrame.BackStack.Clear();
|
||||
SettingsFrame.ForwardStack.Clear();
|
||||
|
||||
NavigateBreadcrumb(new BreadcrumbNavigationRequested(Translator.MenuSettings, WinoPage.SettingOptionsPage));
|
||||
}
|
||||
|
||||
public void ResetForModeSwitch()
|
||||
{
|
||||
while (PageHistory.Count > 1 && SettingsFrame.CanGoBack)
|
||||
|
||||
@@ -127,6 +127,40 @@
|
||||
Grid.Row="3"
|
||||
MaxWidth="600"
|
||||
HorizontalAlignment="Center">
|
||||
<Border
|
||||
Margin="0,0,0,16"
|
||||
Padding="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="12"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportPreview, Mode=OneWay}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind domain:Translator.LegacyLocalMigration_WelcomeSectionTitle, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind domain:Translator.LegacyLocalMigration_WelcomeSectionDescription, Mode=OneTime}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock Text="{x:Bind ViewModel.LegacyImportSummary, Mode=OneWay}" TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.LegacyImportWarnings, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
|
||||
<Button
|
||||
HorizontalAlignment="Center"
|
||||
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
|
||||
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}"
|
||||
Style="{ThemeResource AccentButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<HyperlinkButton
|
||||
HorizontalAlignment="Center"
|
||||
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace Wino.Messaging.UI;
|
||||
|
||||
public record WelcomeImportCompletedMessage(int ImportedMailboxCount) : UIMessageBase<WelcomeImportCompletedMessage>;
|
||||
public record WelcomeImportCompletedMessage(int ImportedMailboxCount, string CompletionMessage = "") : UIMessageBase<WelcomeImportCompletedMessage>;
|
||||
|
||||
@@ -0,0 +1,881 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain;
|
||||
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.Domain.Models.Accounts;
|
||||
using Wino.Messaging.Client.Accounts;
|
||||
|
||||
namespace Wino.Services;
|
||||
|
||||
public sealed class LegacyLocalMigrationService : ILegacyLocalMigrationService
|
||||
{
|
||||
private const string LegacyDatabaseFileName = "Wino180.db";
|
||||
private const string MigrationCompletedSettingKey = "LegacyLocalMigration_v2_Completed";
|
||||
private const string PromptDeferredSettingKey = "LegacyLocalMigration_v2_PromptDeferred";
|
||||
private const int DefaultMaxConcurrentClients = 5;
|
||||
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly IConfigurationService _configurationService;
|
||||
private readonly IDatabaseService _databaseService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
|
||||
private readonly ILogger _logger = Log.ForContext<LegacyLocalMigrationService>();
|
||||
|
||||
public LegacyLocalMigrationService(IApplicationConfiguration applicationConfiguration,
|
||||
IConfigurationService configurationService,
|
||||
IDatabaseService databaseService,
|
||||
IAccountService accountService,
|
||||
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver)
|
||||
{
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_configurationService = configurationService;
|
||||
_databaseService = databaseService;
|
||||
_accountService = accountService;
|
||||
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
|
||||
}
|
||||
|
||||
public void MarkPromptDeferred()
|
||||
=> _configurationService.Set(PromptDeferredSettingKey, true);
|
||||
|
||||
public async Task<LegacyLocalMigrationPreview> DetectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (_, preview) = await LoadPreviewContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
return preview;
|
||||
}
|
||||
|
||||
public async Task<LegacyLocalMigrationResult> ImportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (snapshot, preview) = await LoadPreviewContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot == null || !preview.LegacyDatabaseExists)
|
||||
{
|
||||
return new LegacyLocalMigrationResult
|
||||
{
|
||||
Preview = preview,
|
||||
Warnings = preview.Warnings
|
||||
};
|
||||
}
|
||||
|
||||
_configurationService.Set(PromptDeferredSettingKey, false);
|
||||
|
||||
var failures = new List<LegacyLocalMigrationFailure>();
|
||||
var importedAccounts = new Dictionary<Guid, MailAccount>();
|
||||
var skippedDuplicateAccountCount = preview.Accounts.Count(a => a.IsDuplicate);
|
||||
var importedAccountCount = 0;
|
||||
var currentAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||
var nextOrder = currentAccounts.Count;
|
||||
|
||||
foreach (var previewAccount in preview.Accounts
|
||||
.OrderBy(a => a.Order)
|
||||
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!previewAccount.CanImport ||
|
||||
!snapshot.AccountsById.TryGetValue(previewAccount.LegacyAccountId, out var legacyAccount))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var importedAccount = CreateImportedAccount(legacyAccount, nextOrder);
|
||||
var serverInformation = CreateImportedServerInformation(legacyAccount, importedAccount);
|
||||
|
||||
await _accountService.CreateAccountAsync(importedAccount, serverInformation).ConfigureAwait(false);
|
||||
await _accountService.CreateRootAliasAsync(importedAccount.Id, importedAccount.Address).ConfigureAwait(false);
|
||||
|
||||
ApplyLegacyPreferences(importedAccount, legacyAccount.Preferences);
|
||||
importedAccount.Order = nextOrder;
|
||||
|
||||
await _accountService.UpdateAccountAsync(importedAccount).ConfigureAwait(false);
|
||||
|
||||
importedAccounts[legacyAccount.LegacyAccountId] = importedAccount;
|
||||
importedAccountCount++;
|
||||
nextOrder++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to import legacy account {LegacyAccountId} ({Address})", legacyAccount.LegacyAccountId, legacyAccount.Address);
|
||||
|
||||
failures.Add(new LegacyLocalMigrationFailure
|
||||
{
|
||||
Address = legacyAccount.Address,
|
||||
ProviderType = legacyAccount.ProviderType,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var (importedMergedInboxCount, skippedMergedInboxCount) = await ImportMergedInboxesAsync(snapshot, importedAccounts).ConfigureAwait(false);
|
||||
|
||||
if (importedAccountCount > 0 || importedMergedInboxCount > 0)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false));
|
||||
}
|
||||
|
||||
_configurationService.Set(MigrationCompletedSettingKey, failures.Count == 0);
|
||||
|
||||
return new LegacyLocalMigrationResult
|
||||
{
|
||||
Preview = preview,
|
||||
ImportedAccountCount = importedAccountCount,
|
||||
SkippedDuplicateAccountCount = skippedDuplicateAccountCount,
|
||||
FailedAccountCount = failures.Count,
|
||||
ImportedMergedInboxCount = importedMergedInboxCount,
|
||||
SkippedMergedInboxCount = skippedMergedInboxCount,
|
||||
Failures = failures,
|
||||
Warnings = preview.Warnings
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(LegacySnapshot? Snapshot, LegacyLocalMigrationPreview Preview)> LoadPreviewContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var legacyDatabasePath = GetLegacyDatabasePath();
|
||||
if (string.IsNullOrWhiteSpace(legacyDatabasePath) || !File.Exists(legacyDatabasePath))
|
||||
{
|
||||
return (null, CreateEmptyPreview(legacyDatabasePath));
|
||||
}
|
||||
|
||||
SQLiteAsyncConnection? connection = null;
|
||||
|
||||
try
|
||||
{
|
||||
connection = new SQLiteAsyncConnection(
|
||||
legacyDatabasePath,
|
||||
SQLiteOpenFlags.ReadOnly | SQLiteOpenFlags.SharedCache,
|
||||
storeDateTimeAsTicks: false);
|
||||
|
||||
var snapshot = await LoadSnapshotAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var existingAddressKeys = await TryGetExistingAddressKeysAsync().ConfigureAwait(false);
|
||||
|
||||
return (snapshot, BuildPreview(legacyDatabasePath, snapshot, existingAddressKeys));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to inspect legacy database at {LegacyDatabasePath}", legacyDatabasePath);
|
||||
|
||||
return (null, CreateUnreadablePreview(legacyDatabasePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (connection != null)
|
||||
{
|
||||
await connection.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacySnapshot> LoadSnapshotAsync(SQLiteAsyncConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = new LegacySnapshot();
|
||||
|
||||
var accountColumns = await GetColumnSetAsync(connection, nameof(MailAccount)).ConfigureAwait(false);
|
||||
if (accountColumns.Count == 0)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
var accountRows = await connection.QueryAsync<LegacyMailAccountRow>(BuildMailAccountQuery(accountColumns)).ConfigureAwait(false);
|
||||
snapshot.TotalLegacyAccountCount = accountRows.Count;
|
||||
|
||||
var preferenceRows = await QueryRowsIfTableExistsAsync<LegacyMailAccountPreferencesRow>(
|
||||
connection,
|
||||
nameof(MailAccountPreferences),
|
||||
BuildMailAccountPreferencesQuery).ConfigureAwait(false);
|
||||
|
||||
var serverRows = await QueryRowsIfTableExistsAsync<LegacyCustomServerInformationRow>(
|
||||
connection,
|
||||
nameof(CustomServerInformation),
|
||||
BuildCustomServerInformationQuery).ConfigureAwait(false);
|
||||
|
||||
var mergedInboxRows = await QueryRowsIfTableExistsAsync<LegacyMergedInboxRow>(
|
||||
connection,
|
||||
nameof(MergedInbox),
|
||||
BuildMergedInboxQuery).ConfigureAwait(false);
|
||||
|
||||
snapshot.MergedInboxNamesById = mergedInboxRows
|
||||
.Where(a => a.Id != Guid.Empty && !string.IsNullOrWhiteSpace(a.Name))
|
||||
.GroupBy(a => a.Id)
|
||||
.ToDictionary(a => a.Key, a => NormalizeOptionalText(a.First().Name), EqualityComparer<Guid>.Default);
|
||||
|
||||
var preferencesByAccountId = preferenceRows
|
||||
.Where(a => a.AccountId != Guid.Empty)
|
||||
.GroupBy(a => a.AccountId)
|
||||
.ToDictionary(a => a.Key, a => a.First(), EqualityComparer<Guid>.Default);
|
||||
|
||||
var serverByAccountId = serverRows
|
||||
.Where(a => a.AccountId != Guid.Empty)
|
||||
.GroupBy(a => a.AccountId)
|
||||
.ToDictionary(a => a.Key, a => a.First(), EqualityComparer<Guid>.Default);
|
||||
|
||||
foreach (var row in accountRows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (row.Id == Guid.Empty ||
|
||||
!TryMapProviderType(row.ProviderType, out var providerType))
|
||||
{
|
||||
snapshot.InvalidAccountCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedAddress = NormalizeOptionalText(row.Address);
|
||||
if (string.IsNullOrWhiteSpace(normalizedAddress))
|
||||
{
|
||||
snapshot.InvalidAccountCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
preferencesByAccountId.TryGetValue(row.Id, out var preferences);
|
||||
serverByAccountId.TryGetValue(row.Id, out var serverInformation);
|
||||
|
||||
var candidate = new LegacyAccountCandidate
|
||||
{
|
||||
LegacyAccountId = row.Id,
|
||||
Address = normalizedAddress,
|
||||
Name = NormalizeDisplayName(row.Name, normalizedAddress),
|
||||
SenderName = NormalizeDisplayName(row.SenderName, NormalizeDisplayName(row.Name, normalizedAddress)),
|
||||
ProviderType = providerType,
|
||||
SpecialImapProvider = MapSpecialImapProvider(row.SpecialImapProvider),
|
||||
Order = Math.Max(0, row.Order ?? 0),
|
||||
AccountColorHex = NormalizeOptionalText(row.AccountColorHex),
|
||||
LegacyMergedInboxId = row.MergedInboxId,
|
||||
Preferences = preferences,
|
||||
ServerInformation = serverInformation
|
||||
};
|
||||
|
||||
snapshot.Accounts.Add(candidate);
|
||||
snapshot.AccountsById[candidate.LegacyAccountId] = candidate;
|
||||
}
|
||||
|
||||
snapshot.Accounts = snapshot.Accounts
|
||||
.OrderBy(a => a.Order)
|
||||
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async Task<List<T>> QueryRowsIfTableExistsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(
|
||||
SQLiteAsyncConnection connection,
|
||||
string tableName,
|
||||
Func<HashSet<string>, string> sqlFactory) where T : new()
|
||||
{
|
||||
var columns = await GetColumnSetAsync(connection, tableName).ConfigureAwait(false);
|
||||
if (columns.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await connection.QueryAsync<T>(sqlFactory(columns)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetColumnSetAsync(SQLiteAsyncConnection connection, string tableName)
|
||||
{
|
||||
var tableInfo = await connection.GetTableInfoAsync(tableName).ConfigureAwait(false);
|
||||
return tableInfo
|
||||
.Select(a => a.Name)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> TryGetExistingAddressKeysAsync()
|
||||
{
|
||||
if (_databaseService.Connection == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
|
||||
return accounts
|
||||
.Select(a => CreateAddressKey(a.Address))
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Skipping duplicate detection against the current database because account data is not available yet.");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyLocalMigrationPreview BuildPreview(string legacyDatabasePath, LegacySnapshot snapshot, HashSet<string> existingAddressKeys)
|
||||
{
|
||||
var seenAddressKeys = new HashSet<string>(existingAddressKeys, StringComparer.Ordinal);
|
||||
var accountPreviewItems = new List<LegacyLocalMigrationAccountPreview>();
|
||||
|
||||
foreach (var account in snapshot.Accounts)
|
||||
{
|
||||
var addressKey = CreateAddressKey(account.Address);
|
||||
var isDuplicate = !seenAddressKeys.Add(addressKey);
|
||||
var isCalendarEnabled = account.ProviderType switch
|
||||
{
|
||||
MailProviderType.Outlook or MailProviderType.Gmail => true,
|
||||
MailProviderType.IMAP4 => ResolveCalendarSupportMode(account) != ImapCalendarSupportMode.Disabled,
|
||||
_ => false
|
||||
};
|
||||
|
||||
accountPreviewItems.Add(new LegacyLocalMigrationAccountPreview
|
||||
{
|
||||
LegacyAccountId = account.LegacyAccountId,
|
||||
Address = account.Address,
|
||||
DisplayName = account.Name,
|
||||
ProviderType = account.ProviderType,
|
||||
SpecialImapProvider = account.SpecialImapProvider,
|
||||
Order = account.Order,
|
||||
CanImport = !isDuplicate,
|
||||
IsDuplicate = isDuplicate,
|
||||
IsCalendarEnabled = isCalendarEnabled
|
||||
});
|
||||
}
|
||||
|
||||
var importableMergedInboxCount = 0;
|
||||
var skippedMergedInboxCount = 0;
|
||||
|
||||
foreach (var group in accountPreviewItems
|
||||
.Where(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId.HasValue)
|
||||
.GroupBy(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId!.Value))
|
||||
{
|
||||
var members = group.ToList();
|
||||
var hasReadableMergedInbox = snapshot.MergedInboxNamesById.TryGetValue(group.Key, out var mergedInboxName) &&
|
||||
!string.IsNullOrWhiteSpace(mergedInboxName);
|
||||
|
||||
if (members.Count >= 2 && hasReadableMergedInbox && members.All(a => a.CanImport))
|
||||
{
|
||||
importableMergedInboxCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedMergedInboxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var providerCounts = accountPreviewItems
|
||||
.GroupBy(a => a.ProviderType)
|
||||
.OrderBy(a => a.Key)
|
||||
.Select(group => new LegacyLocalMigrationProviderCount
|
||||
{
|
||||
ProviderType = group.Key,
|
||||
TotalAccountCount = group.Count(),
|
||||
ImportableAccountCount = group.Count(a => a.CanImport),
|
||||
DuplicateAccountCount = group.Count(a => a.IsDuplicate)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
|
||||
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
|
||||
var importableAccountCount = accountPreviewItems.Count(a => a.CanImport);
|
||||
var warnings = BuildWarnings(snapshot, accountPreviewItems, skippedMergedInboxCount);
|
||||
|
||||
return new LegacyLocalMigrationPreview
|
||||
{
|
||||
SourceDatabasePath = legacyDatabasePath,
|
||||
LegacyDatabaseExists = true,
|
||||
HasCompletedMigration = hasCompletedMigration,
|
||||
IsPromptDeferred = isPromptDeferred,
|
||||
ShouldPrompt = importableAccountCount > 0 && !hasCompletedMigration && !isPromptDeferred,
|
||||
LegacyAccountCount = snapshot.TotalLegacyAccountCount,
|
||||
ImportableAccountCount = importableAccountCount,
|
||||
DuplicateAccountCount = accountPreviewItems.Count(a => a.IsDuplicate),
|
||||
SkippedAccountCount = snapshot.InvalidAccountCount,
|
||||
ImportableMergedInboxCount = importableMergedInboxCount,
|
||||
SkippedMergedInboxCount = skippedMergedInboxCount,
|
||||
ProviderCounts = providerCounts,
|
||||
Accounts = accountPreviewItems,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildWarnings(LegacySnapshot snapshot,
|
||||
IReadOnlyCollection<LegacyLocalMigrationAccountPreview> accountPreviewItems,
|
||||
int skippedMergedInboxCount)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (accountPreviewItems.Any(a => a.CanImport && (a.ProviderType == MailProviderType.Outlook || a.ProviderType == MailProviderType.Gmail)))
|
||||
{
|
||||
warnings.Add(Translator.LegacyLocalMigration_Warning_OAuth);
|
||||
}
|
||||
|
||||
if (accountPreviewItems.Any(a => a.CanImport && a.ProviderType == MailProviderType.IMAP4))
|
||||
{
|
||||
warnings.Add(Translator.LegacyLocalMigration_Warning_Imap);
|
||||
}
|
||||
|
||||
if (accountPreviewItems.Any(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId.HasValue))
|
||||
{
|
||||
warnings.Add(Translator.LegacyLocalMigration_Warning_Merged);
|
||||
}
|
||||
|
||||
if (snapshot.InvalidAccountCount > 0)
|
||||
{
|
||||
warnings.Add(string.Format(Translator.LegacyLocalMigration_Warning_SkippedAccounts, snapshot.InvalidAccountCount));
|
||||
}
|
||||
|
||||
if (skippedMergedInboxCount > 0)
|
||||
{
|
||||
warnings.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSkipped, skippedMergedInboxCount));
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private MailAccount CreateImportedAccount(LegacyAccountCandidate account, int order)
|
||||
{
|
||||
var isCalendarAccessGranted = account.ProviderType switch
|
||||
{
|
||||
MailProviderType.Outlook or MailProviderType.Gmail => true,
|
||||
MailProviderType.IMAP4 => ResolveCalendarSupportMode(account) != ImapCalendarSupportMode.Disabled,
|
||||
_ => false
|
||||
};
|
||||
|
||||
return new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Address = account.Address,
|
||||
Name = NormalizeDisplayName(account.Name, account.Address),
|
||||
SenderName = NormalizeDisplayName(account.SenderName, NormalizeDisplayName(account.Name, account.Address)),
|
||||
ProviderType = account.ProviderType,
|
||||
SpecialImapProvider = account.SpecialImapProvider,
|
||||
SynchronizationDeltaIdentifier = string.Empty,
|
||||
CalendarSynchronizationDeltaIdentifier = string.Empty,
|
||||
AccountColorHex = account.AccountColorHex,
|
||||
Base64ProfilePictureData = string.Empty,
|
||||
Order = order,
|
||||
AttentionReason = AccountAttentionReason.InvalidCredentials,
|
||||
IsMailAccessGranted = true,
|
||||
IsCalendarAccessGranted = isCalendarAccessGranted,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
InitialSynchronizationRange = InitialSynchronizationRange.SixMonths
|
||||
};
|
||||
}
|
||||
|
||||
private CustomServerInformation? CreateImportedServerInformation(LegacyAccountCandidate account, MailAccount importedAccount)
|
||||
{
|
||||
if (account.ProviderType != MailProviderType.IMAP4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var legacyServer = account.ServerInformation;
|
||||
var fallbackServer = GetSpecialProviderFallback(account);
|
||||
|
||||
return new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = importedAccount.Id,
|
||||
Address = importedAccount.Address,
|
||||
IncomingServer = FirstNonEmpty(legacyServer?.IncomingServer, fallbackServer?.IncomingServer),
|
||||
IncomingServerUsername = FirstNonEmpty(legacyServer?.IncomingServerUsername, fallbackServer?.IncomingServerUsername),
|
||||
IncomingServerPassword = string.Empty,
|
||||
IncomingServerPort = FirstNonEmpty(legacyServer?.IncomingServerPort, fallbackServer?.IncomingServerPort),
|
||||
IncomingServerType = CustomIncomingServerType.IMAP4,
|
||||
OutgoingServer = FirstNonEmpty(legacyServer?.OutgoingServer, fallbackServer?.OutgoingServer),
|
||||
OutgoingServerPort = FirstNonEmpty(legacyServer?.OutgoingServerPort, fallbackServer?.OutgoingServerPort),
|
||||
OutgoingServerUsername = FirstNonEmpty(legacyServer?.OutgoingServerUsername, fallbackServer?.OutgoingServerUsername),
|
||||
OutgoingServerPassword = string.Empty,
|
||||
CalDavServiceUrl = FirstNonEmpty(legacyServer?.CalDavServiceUrl, fallbackServer?.CalDavServiceUrl),
|
||||
CalDavUsername = FirstNonEmpty(legacyServer?.CalDavUsername, fallbackServer?.CalDavUsername),
|
||||
CalDavPassword = string.Empty,
|
||||
CalendarSupportMode = ResolveCalendarSupportMode(account),
|
||||
IncomingServerSocketOption = MapConnectionSecurity(legacyServer?.IncomingServerSocketOption, fallbackServer?.IncomingServerSocketOption),
|
||||
IncomingAuthenticationMethod = MapAuthenticationMethod(legacyServer?.IncomingAuthenticationMethod, fallbackServer?.IncomingAuthenticationMethod),
|
||||
OutgoingServerSocketOption = MapConnectionSecurity(legacyServer?.OutgoingServerSocketOption, fallbackServer?.OutgoingServerSocketOption),
|
||||
OutgoingAuthenticationMethod = MapAuthenticationMethod(legacyServer?.OutgoingAuthenticationMethod, fallbackServer?.OutgoingAuthenticationMethod),
|
||||
ProxyServer = NormalizeOptionalText(legacyServer?.ProxyServer),
|
||||
ProxyServerPort = NormalizeOptionalText(legacyServer?.ProxyServerPort),
|
||||
MaxConcurrentClients = legacyServer?.MaxConcurrentClients is int maxConcurrentClients && maxConcurrentClients > 0
|
||||
? maxConcurrentClients
|
||||
: fallbackServer?.MaxConcurrentClients > 0
|
||||
? fallbackServer.MaxConcurrentClients
|
||||
: DefaultMaxConcurrentClients
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyLegacyPreferences(MailAccount account, LegacyMailAccountPreferencesRow? legacyPreferences)
|
||||
{
|
||||
if (account.Preferences == null || legacyPreferences == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyPreferences.IsNotificationsEnabled.HasValue)
|
||||
{
|
||||
account.Preferences.IsNotificationsEnabled = legacyPreferences.IsNotificationsEnabled.Value;
|
||||
}
|
||||
|
||||
if (legacyPreferences.IsTaskbarBadgeEnabled.HasValue)
|
||||
{
|
||||
account.Preferences.IsTaskbarBadgeEnabled = legacyPreferences.IsTaskbarBadgeEnabled.Value;
|
||||
}
|
||||
|
||||
if (legacyPreferences.ShouldAppendMessagesToSentFolder.HasValue)
|
||||
{
|
||||
account.Preferences.ShouldAppendMessagesToSentFolder = legacyPreferences.ShouldAppendMessagesToSentFolder.Value;
|
||||
}
|
||||
|
||||
if (account.ProviderType == MailProviderType.Outlook && legacyPreferences.IsFocusedInboxEnabled.HasValue)
|
||||
{
|
||||
account.Preferences.IsFocusedInboxEnabled = legacyPreferences.IsFocusedInboxEnabled.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int ImportedCount, int SkippedCount)> ImportMergedInboxesAsync(LegacySnapshot snapshot, IReadOnlyDictionary<Guid, MailAccount> importedAccounts)
|
||||
{
|
||||
var importedCount = 0;
|
||||
var skippedCount = 0;
|
||||
|
||||
foreach (var group in snapshot.Accounts
|
||||
.Where(a => a.LegacyMergedInboxId.HasValue)
|
||||
.GroupBy(a => a.LegacyMergedInboxId!.Value))
|
||||
{
|
||||
var members = group.ToList();
|
||||
if (members.Count < 2 ||
|
||||
!snapshot.MergedInboxNamesById.TryGetValue(group.Key, out var mergedInboxName) ||
|
||||
string.IsNullOrWhiteSpace(mergedInboxName))
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var importedMembers = members
|
||||
.Where(a => importedAccounts.ContainsKey(a.LegacyAccountId))
|
||||
.Select(a => importedAccounts[a.LegacyAccountId])
|
||||
.ToList();
|
||||
|
||||
if (importedMembers.Count != members.Count)
|
||||
{
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _accountService.CreateMergeAccountsAsync(
|
||||
new MergedInbox { Name = mergedInboxName },
|
||||
importedMembers).ConfigureAwait(false);
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to import legacy merged inbox {LegacyMergedInboxId}", group.Key);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return (importedCount, skippedCount);
|
||||
}
|
||||
|
||||
private CustomServerInformation? GetSpecialProviderFallback(LegacyAccountCandidate account)
|
||||
{
|
||||
if (account.SpecialImapProvider == SpecialImapProvider.None)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _specialImapProviderConfigResolver.GetServerInformation(
|
||||
new MailAccount
|
||||
{
|
||||
Address = account.Address,
|
||||
SenderName = account.SenderName,
|
||||
ProviderType = MailProviderType.IMAP4,
|
||||
SpecialImapProvider = account.SpecialImapProvider
|
||||
},
|
||||
new AccountCreationDialogResult(
|
||||
MailProviderType.IMAP4,
|
||||
account.Name,
|
||||
new SpecialImapProviderDetails(
|
||||
account.Address,
|
||||
string.Empty,
|
||||
account.SenderName,
|
||||
account.SpecialImapProvider,
|
||||
ImapCalendarSupportMode.CalDav),
|
||||
account.AccountColorHex,
|
||||
InitialSynchronizationRange.SixMonths,
|
||||
true,
|
||||
true));
|
||||
}
|
||||
|
||||
private static ImapCalendarSupportMode ResolveCalendarSupportMode(LegacyAccountCandidate account)
|
||||
{
|
||||
var rawValue = account.ServerInformation?.CalendarSupportMode;
|
||||
|
||||
return rawValue is int intValue && Enum.IsDefined(typeof(ImapCalendarSupportMode), intValue)
|
||||
? (ImapCalendarSupportMode)intValue
|
||||
: ImapCalendarSupportMode.Disabled;
|
||||
}
|
||||
|
||||
private static ImapConnectionSecurity MapConnectionSecurity(int? rawValue, ImapConnectionSecurity? fallbackValue)
|
||||
{
|
||||
if (rawValue.HasValue && Enum.IsDefined(typeof(ImapConnectionSecurity), rawValue.Value))
|
||||
{
|
||||
return (ImapConnectionSecurity)rawValue.Value;
|
||||
}
|
||||
|
||||
return fallbackValue ?? ImapConnectionSecurity.Auto;
|
||||
}
|
||||
|
||||
private static ImapAuthenticationMethod MapAuthenticationMethod(int? rawValue, ImapAuthenticationMethod? fallbackValue)
|
||||
{
|
||||
if (rawValue.HasValue && Enum.IsDefined(typeof(ImapAuthenticationMethod), rawValue.Value))
|
||||
{
|
||||
return (ImapAuthenticationMethod)rawValue.Value;
|
||||
}
|
||||
|
||||
return fallbackValue ?? ImapAuthenticationMethod.Auto;
|
||||
}
|
||||
|
||||
private static bool TryMapProviderType(int? rawValue, out MailProviderType providerType)
|
||||
{
|
||||
providerType = default;
|
||||
|
||||
if (!rawValue.HasValue ||
|
||||
!Enum.IsDefined(typeof(MailProviderType), rawValue.Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
providerType = (MailProviderType)rawValue.Value;
|
||||
|
||||
return providerType is MailProviderType.Outlook or MailProviderType.Gmail or MailProviderType.IMAP4;
|
||||
}
|
||||
|
||||
private static SpecialImapProvider MapSpecialImapProvider(int? rawValue)
|
||||
{
|
||||
if (!rawValue.HasValue ||
|
||||
!Enum.IsDefined(typeof(SpecialImapProvider), rawValue.Value))
|
||||
{
|
||||
return SpecialImapProvider.None;
|
||||
}
|
||||
|
||||
return (SpecialImapProvider)rawValue.Value;
|
||||
}
|
||||
|
||||
private static string BuildMailAccountQuery(HashSet<string> columns)
|
||||
{
|
||||
return $"""
|
||||
SELECT
|
||||
{SelectColumnOrFallback(columns, "Id")},
|
||||
{SelectColumnOrFallback(columns, "Address")},
|
||||
{SelectColumnOrFallback(columns, "Name")},
|
||||
{SelectColumnOrFallback(columns, "SenderName")},
|
||||
{SelectColumnOrFallback(columns, "ProviderType")},
|
||||
{SelectColumnOrFallback(columns, "SpecialImapProvider")},
|
||||
{SelectColumnOrFallback(columns, "Order", "0")},
|
||||
{SelectColumnOrFallback(columns, "AccountColorHex")},
|
||||
{SelectColumnOrFallback(columns, "MergedInboxId")}
|
||||
FROM [{nameof(MailAccount)}]
|
||||
ORDER BY [Order] ASC, [Address] COLLATE NOCASE
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildMailAccountPreferencesQuery(HashSet<string> columns)
|
||||
{
|
||||
return $"""
|
||||
SELECT
|
||||
{SelectColumnOrFallback(columns, "AccountId")},
|
||||
{SelectColumnOrFallback(columns, "IsNotificationsEnabled")},
|
||||
{SelectColumnOrFallback(columns, "IsTaskbarBadgeEnabled")},
|
||||
{SelectColumnOrFallback(columns, "ShouldAppendMessagesToSentFolder")},
|
||||
{SelectColumnOrFallback(columns, "IsFocusedInboxEnabled")}
|
||||
FROM [{nameof(MailAccountPreferences)}]
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildCustomServerInformationQuery(HashSet<string> columns)
|
||||
{
|
||||
return $"""
|
||||
SELECT
|
||||
{SelectColumnOrFallback(columns, "AccountId")},
|
||||
{SelectColumnOrFallback(columns, "Address")},
|
||||
{SelectColumnOrFallback(columns, "IncomingServer")},
|
||||
{SelectColumnOrFallback(columns, "IncomingServerPort")},
|
||||
{SelectColumnOrFallback(columns, "IncomingServerUsername")},
|
||||
{SelectColumnOrFallback(columns, "IncomingServerSocketOption")},
|
||||
{SelectColumnOrFallback(columns, "IncomingAuthenticationMethod")},
|
||||
{SelectColumnOrFallback(columns, "OutgoingServer")},
|
||||
{SelectColumnOrFallback(columns, "OutgoingServerPort")},
|
||||
{SelectColumnOrFallback(columns, "OutgoingServerUsername")},
|
||||
{SelectColumnOrFallback(columns, "OutgoingServerSocketOption")},
|
||||
{SelectColumnOrFallback(columns, "OutgoingAuthenticationMethod")},
|
||||
{SelectColumnOrFallback(columns, "CalDavServiceUrl")},
|
||||
{SelectColumnOrFallback(columns, "CalDavUsername")},
|
||||
{SelectColumnOrFallback(columns, "CalendarSupportMode")},
|
||||
{SelectColumnOrFallback(columns, "ProxyServer")},
|
||||
{SelectColumnOrFallback(columns, "ProxyServerPort")},
|
||||
{SelectColumnOrFallback(columns, "MaxConcurrentClients")}
|
||||
FROM [{nameof(CustomServerInformation)}]
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildMergedInboxQuery(HashSet<string> columns)
|
||||
{
|
||||
return $"""
|
||||
SELECT
|
||||
{SelectColumnOrFallback(columns, "Id")},
|
||||
{SelectColumnOrFallback(columns, "Name")}
|
||||
FROM [{nameof(MergedInbox)}]
|
||||
""";
|
||||
}
|
||||
|
||||
private static string SelectColumnOrFallback(HashSet<string> columns, string columnName, string fallbackSql = "NULL")
|
||||
{
|
||||
return columns.Contains(columnName)
|
||||
? $"[{columnName}] AS [{columnName}]"
|
||||
: $"{fallbackSql} AS [{columnName}]";
|
||||
}
|
||||
|
||||
private static string NormalizeDisplayName(string? value, string fallback)
|
||||
{
|
||||
var normalized = NormalizeOptionalText(value);
|
||||
return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeOptionalText(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Trim();
|
||||
|
||||
private static string FirstNonEmpty(string? primary, string? secondary)
|
||||
{
|
||||
var normalizedPrimary = NormalizeOptionalText(primary);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedPrimary))
|
||||
{
|
||||
return normalizedPrimary;
|
||||
}
|
||||
|
||||
return NormalizeOptionalText(secondary);
|
||||
}
|
||||
|
||||
private static string CreateAddressKey(string? address)
|
||||
=> NormalizeOptionalText(address).ToLowerInvariant();
|
||||
|
||||
private string GetLegacyDatabasePath()
|
||||
{
|
||||
var publisherSharedFolderPath = _applicationConfiguration.PublisherSharedFolderPath;
|
||||
return string.IsNullOrWhiteSpace(publisherSharedFolderPath)
|
||||
? string.Empty
|
||||
: Path.Combine(publisherSharedFolderPath, LegacyDatabaseFileName);
|
||||
}
|
||||
|
||||
private LegacyLocalMigrationPreview CreateEmptyPreview(string legacyDatabasePath)
|
||||
{
|
||||
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
|
||||
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
|
||||
|
||||
return new LegacyLocalMigrationPreview
|
||||
{
|
||||
SourceDatabasePath = legacyDatabasePath,
|
||||
LegacyDatabaseExists = false,
|
||||
HasCompletedMigration = hasCompletedMigration,
|
||||
IsPromptDeferred = isPromptDeferred,
|
||||
ShouldPrompt = false
|
||||
};
|
||||
}
|
||||
|
||||
private LegacyLocalMigrationPreview CreateUnreadablePreview(string legacyDatabasePath)
|
||||
{
|
||||
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
|
||||
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
|
||||
|
||||
return new LegacyLocalMigrationPreview
|
||||
{
|
||||
SourceDatabasePath = legacyDatabasePath,
|
||||
LegacyDatabaseExists = true,
|
||||
HasCompletedMigration = hasCompletedMigration,
|
||||
IsPromptDeferred = isPromptDeferred,
|
||||
ShouldPrompt = false,
|
||||
Warnings = [Translator.LegacyLocalMigration_Warning_ReadFailed]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class LegacySnapshot
|
||||
{
|
||||
public int TotalLegacyAccountCount { get; set; }
|
||||
public int InvalidAccountCount { get; set; }
|
||||
public List<LegacyAccountCandidate> Accounts { get; set; } = [];
|
||||
public Dictionary<Guid, LegacyAccountCandidate> AccountsById { get; } = [];
|
||||
public Dictionary<Guid, string> MergedInboxNamesById { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class LegacyAccountCandidate
|
||||
{
|
||||
public Guid LegacyAccountId { get; init; }
|
||||
public string Address { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string SenderName { get; init; } = string.Empty;
|
||||
public MailProviderType ProviderType { get; init; }
|
||||
public SpecialImapProvider SpecialImapProvider { get; init; }
|
||||
public int Order { get; init; }
|
||||
public string AccountColorHex { get; init; } = string.Empty;
|
||||
public Guid? LegacyMergedInboxId { get; init; }
|
||||
public LegacyMailAccountPreferencesRow? Preferences { get; init; }
|
||||
public LegacyCustomServerInformationRow? ServerInformation { get; init; }
|
||||
}
|
||||
|
||||
private sealed class LegacyMailAccountRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? SenderName { get; set; }
|
||||
public int? ProviderType { get; set; }
|
||||
public int? SpecialImapProvider { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public string? AccountColorHex { get; set; }
|
||||
public Guid? MergedInboxId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class LegacyMailAccountPreferencesRow
|
||||
{
|
||||
public Guid AccountId { get; set; }
|
||||
public bool? ShouldAppendMessagesToSentFolder { get; set; }
|
||||
public bool? IsNotificationsEnabled { get; set; }
|
||||
public bool? IsFocusedInboxEnabled { get; set; }
|
||||
public bool? IsTaskbarBadgeEnabled { get; set; }
|
||||
}
|
||||
|
||||
private sealed class LegacyCustomServerInformationRow
|
||||
{
|
||||
public Guid AccountId { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? IncomingServer { get; set; }
|
||||
public string? IncomingServerPort { get; set; }
|
||||
public string? IncomingServerUsername { get; set; }
|
||||
public int? IncomingServerSocketOption { get; set; }
|
||||
public int? IncomingAuthenticationMethod { get; set; }
|
||||
public string? OutgoingServer { get; set; }
|
||||
public string? OutgoingServerPort { get; set; }
|
||||
public string? OutgoingServerUsername { get; set; }
|
||||
public int? OutgoingServerSocketOption { get; set; }
|
||||
public int? OutgoingAuthenticationMethod { get; set; }
|
||||
public string? CalDavServiceUrl { get; set; }
|
||||
public string? CalDavUsername { get; set; }
|
||||
public int? CalendarSupportMode { get; set; }
|
||||
public string? ProxyServer { get; set; }
|
||||
public string? ProxyServerPort { get; set; }
|
||||
public int? MaxConcurrentClients { get; set; }
|
||||
}
|
||||
|
||||
private sealed class LegacyMergedInboxRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Wino.Core.Tests")]
|
||||
@@ -30,6 +30,7 @@ public static class ServicesContainerSetup
|
||||
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
||||
services.AddTransient<ICalendarContextMenuItemService, CalendarContextMenuItemService>();
|
||||
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
||||
services.AddTransient<ILegacyLocalMigrationService, LegacyLocalMigrationService>();
|
||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
@@ -19,17 +20,19 @@ public class TranslationService : ITranslationService
|
||||
|
||||
private ILogger _logger = Log.ForContext<TranslationService>();
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private readonly IConfigurationService _configurationService;
|
||||
private bool isInitialized = false;
|
||||
|
||||
public AppLanguageModel CurrentLanguageModel { get; private set; }
|
||||
|
||||
public TranslationService(IPreferencesService preferencesService)
|
||||
public TranslationService(IPreferencesService preferencesService, IConfigurationService configurationService)
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
_configurationService = configurationService;
|
||||
}
|
||||
|
||||
// Initialize default language with ignoring current language check.
|
||||
public Task InitializeAsync() => InitializeLanguageAsync(_preferencesService.CurrentLanguage, ignoreCurrentLanguageCheck: true);
|
||||
public Task InitializeAsync() => InitializeLanguageAsync(GetInitialLanguage(), ignoreCurrentLanguageCheck: true);
|
||||
|
||||
public async Task InitializeLanguageAsync(AppLanguage language, bool ignoreCurrentLanguageCheck = false)
|
||||
{
|
||||
@@ -65,6 +68,67 @@ public class TranslationService : ITranslationService
|
||||
WeakReferenceMessenger.Default.Send(new LanguageChanged());
|
||||
}
|
||||
|
||||
private AppLanguage GetInitialLanguage()
|
||||
{
|
||||
if (_configurationService.Contains(nameof(IPreferencesService.CurrentLanguage)))
|
||||
return _preferencesService.CurrentLanguage;
|
||||
|
||||
var windowsDisplayLanguage = CultureInfo.CurrentUICulture?.Name;
|
||||
var initialLanguage = ResolveSupportedLanguage(
|
||||
[
|
||||
windowsDisplayLanguage ?? string.Empty,
|
||||
CultureInfo.CurrentUICulture?.TwoLetterISOLanguageName ?? string.Empty
|
||||
]);
|
||||
|
||||
_logger.Information("No saved app language preference found. Using Windows display language {LanguageTag} -> {Language}.",
|
||||
string.IsNullOrWhiteSpace(windowsDisplayLanguage) ? "<unknown>" : windowsDisplayLanguage,
|
||||
initialLanguage);
|
||||
|
||||
return initialLanguage;
|
||||
}
|
||||
|
||||
internal static AppLanguage ResolveSupportedLanguage(IEnumerable<string> languageTags)
|
||||
{
|
||||
foreach (var languageTag in languageTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(languageTag))
|
||||
continue;
|
||||
|
||||
var normalizedLanguageTag = languageTag.Replace('_', '-').Trim();
|
||||
var languageCode = normalizedLanguageTag.Split('-')[0];
|
||||
|
||||
if (TryResolveSupportedLanguage(languageCode, out var supportedLanguage))
|
||||
return supportedLanguage;
|
||||
}
|
||||
|
||||
return DefaultAppLanguage;
|
||||
}
|
||||
|
||||
private static bool TryResolveSupportedLanguage(string languageCode, out AppLanguage supportedLanguage)
|
||||
{
|
||||
supportedLanguage = languageCode.ToLowerInvariant() switch
|
||||
{
|
||||
"cs" => AppLanguage.Czech,
|
||||
"de" => AppLanguage.Deutsch,
|
||||
"el" => AppLanguage.Greek,
|
||||
"en" => AppLanguage.English,
|
||||
"es" => AppLanguage.Spanish,
|
||||
"fr" => AppLanguage.French,
|
||||
"id" => AppLanguage.Indonesian,
|
||||
"it" => AppLanguage.Italian,
|
||||
"ko" => AppLanguage.Korean,
|
||||
"pl" => AppLanguage.Polish,
|
||||
"pt" => AppLanguage.PortugeseBrazil,
|
||||
"ro" => AppLanguage.Romanian,
|
||||
"ru" => AppLanguage.Russian,
|
||||
"tr" => AppLanguage.Turkish,
|
||||
"zh" => AppLanguage.Chinese,
|
||||
_ => AppLanguage.None
|
||||
};
|
||||
|
||||
return supportedLanguage != AppLanguage.None;
|
||||
}
|
||||
|
||||
public List<AppLanguageModel> GetAvailableLanguages()
|
||||
{
|
||||
return
|
||||
@@ -79,6 +143,7 @@ public class TranslationService : ITranslationService
|
||||
new AppLanguageModel(AppLanguage.Indonesian, "Indonesian", "id-ID"),
|
||||
new AppLanguageModel(AppLanguage.Polish, "Polski", "pl-PL"),
|
||||
new AppLanguageModel(AppLanguage.PortugeseBrazil, "Portuguese-Brazil", "pt-BR"),
|
||||
new AppLanguageModel(AppLanguage.Korean, "Korean", "ko-KR"),
|
||||
new AppLanguageModel(AppLanguage.Russian, "Russian", "ru-RU"),
|
||||
new AppLanguageModel(AppLanguage.Romanian, "Romanian", "ro-RO"),
|
||||
new AppLanguageModel(AppLanguage.Spanish, "Spanish", "es-ES"),
|
||||
|
||||
@@ -47,6 +47,7 @@ LOCALE_LABELS = {
|
||||
"id_ID": "Indonesian (Indonesia)",
|
||||
"it_IT": "Italian (Italy)",
|
||||
"ja_JP": "Japanese (Japan)",
|
||||
"ko_KR": "Korean (South Korea)",
|
||||
"lt_LT": "Lithuanian (Lithuania)",
|
||||
"nl_NL": "Dutch (Netherlands)",
|
||||
"pl_PL": "Polish (Poland)",
|
||||
|
||||
Reference in New Issue
Block a user